Exporting an embeddable React component from a Gatsby app using Webpack

A recent project called for the header and footer—implemented as React components in Gatsby—to be embedded on third-party sites (think support documentation). Since both components rely on Gatsby’s Link component as well as its GraphQL-backed Static Query (useStaticQuery in this case), some creativity was required.

The solution: a separate Webpack configuration that would import the relevant file (e.g. the Header component) into a lightweight wrapper that included an explicit call to ReactDOM.render. To avoid a direct dependency on Gatsby, Webpack’s resolve directive was used to direct imports from Gatsby to a shim.

Here’s the Header component that needs to be embedded. Note the multiple imports from Gatsby:

// src/components/Header.jsx
import React from "react"
import { Link, useStaticQuery, graphql } from "gatsby"

const query = `…` // query fetching link data from CMS

const Header = () => {
  const { navigation } = useStaticQuery(query)
  return (
    <div>Markup here making use of navigation data.</div>
  )
}

This is the embedded widget’s index.js where we import the Header component and render it to a predetermined element on the page:

import React from "react"
import ReactDOM from "react-dom"
import Header from "components/Header"

const baseStyles = {
  fontFamily: "Helvetica, Arial, sans-serif",
  lineHeight: 1.4,
  background: theme.white,
  color: "#444",
  fontSize: 16,
  boxSizing: "border-box",
  [["& *", "& *:before", "& *:after"]]: { boxSizing: "inherit" },
}

const HeaderEmbed = () => (
  <div css={baseStyles}>
    <Header />
  </div>
)

const headerTarget = document.querySelector("[data-render='embed-header']")
if (headerTarget) {
  ReactDOM.render(<HeaderEmbed />, headerTarget)
}

Getting Data from Gatsby into the Embed Component #

Gatsby renders out the result of static queries to JSON files at build time. These files each live at public/page-data/sq/d/${queryHash}.json, where the queryHash is a murmurhash of the query text itself using a seed of “abc”. This same hash is used to retrieve the data client-side when useStaticQuery is called (Gatsby replaces the query string itself with the hash).

# hash-query.js
const {
  stripIgnoredCharacters,
} = require("graphql/utilities/stripIgnoredCharacters")
const { murmurhash } = require("babel-plugin-remove-graphql-queries/murmur")

const GATSBY_HASH_SEED = "abc"

// This must match Gatsby's internal hashing
// https://github.com/gatsbyjs/gatsby/blob/eafb8c6e346188f27cf1687d26544c582ed5952a/packages/babel-plugin-remove-graphql-queries/src/index.js#L144
function hashQuery(query) {
  return murmurhash(stripIgnoredCharacters(query), GATSBY_HASH_SEED).toString()
}

module.exports = hashQuery

To dynamically import this JSON file into the embed, the query needs to be extracted from the src/components/Header.jsx file much the same way Gatsby does. Since this usage is narrower, the solution can take a shortcut from the full static analysis that Gatsby performs and use a simple regular expression to match and extract the query. This is all performed in the extractGraphQLQuery function in the below file.

With the query text determined, it’s just a matter of computing the queryHash, reading the JSON file, and writing that data to a non-dynamic path that can be provided to the Header component when it calls useStaticQuery. This script (prepare-imports.js) will be run as a prebuild step for our embed, which is built after Gatsby has finished building:

// embed/prepare-imports.js
const fs = require("fs")
const path = require("path")
const hashQuery = require("./hash-query")

// Quick and rough routine to extract a single
// graphql tagged template literal from a JS file
function extractGraphQLQuery(sourcePath) {
  const data = fs.readFileSync(
    path.resolve(__dirname, "..", sourcePath),
    "utf8"
  )
  const match = /graphql`([^`]+)`/.exec(data)
  if (match) {
    return match[1]
  }
}

// Copy a cached graphql query result from `public`
// to the embed plugin folder and rename to ensure
// stable imports.
function prepareQueryForImport(
  name,
  sourcePath,
  targetFilename = `${name}.json`
) {
  const query = extractGraphQLQuery(sourcePath)
  const hash = hashQuery(query)

  const source = path.resolve(
    __dirname,
    "..",
    "public",
    "page-data",
    "sq",
    "d",
    `${hash}.json`
  )
  const dest = path.resolve(__dirname, "data", targetFilename)

  fs.copyFileSync(source, dest)

  return { name, hash, dest }
}

const queries = [
  prepareQueryForImport("header", "src/components/Header.js"),
  // Additional files can be added here as needed
]

// Export query data including hashes for reference in gatsby-shim.js
fs.writeFileSync(
  path.resolve(__dirname, "data", "queries.json"),
  JSON.stringify(queries)
)

This builds a file at embed/data/queries.json that looks like this:

[{
  "name": "header",
  "hash": "1287877988",
  "dest": "/absolute/path/to/example-project/embed/data/header.json"
}]

This queries.json dictionary can then be used to substitute in the correct data in the shimmed Gatsby imports:

// embed/gatsby-shim.js
// This file acts as a shim for components of Gatsby that
// are in use in shared components but need to be used outside
// of Gatsby in the embed.

import React from "react"
import hashQuery from "./hash-query"

import queries from "./data/queries.json"

// Import each query data file (as referenced in queries.json)
import headerData from "./data/header.json"

// Build a reference map between the query name (e.g. header)
// and the imported data for it.
const queryData = {
  header: headerData,
}

// Since Gatsby isn't being run, the query data actually
// gets passed to the `graphql` function, which allows us
// to compute its hash similar to how Gatsby does. The
// return value ends up being passed to `useStaticQuery`.
export const graphql = query => hashQuery(query.raw[0])

// We're using the query hash to determine which cached
// result data to return. This needs to match the return
// value from the native Gatsby function for compatibility.
export const useStaticQuery = queryHash => {
  const query = queries.find(query => query.hash === queryHash)
  if (!query) {
    throw new Error(`No matching query for ${queryHash}`)
  }
  return queryData[query.name].data
}

export const Link = ({
  activeClassName,
  activeStyle,
  getProps,
  innerRef,
  partiallyActive,
  ref,
  replace,
  to,
  ...rest
}) =>
  React.createElement("a", {
    ...rest,
    href: to,
  })

export default {}

With all of that established, Webpack can be configured with the following goals:

  1. Import the embed/index.js embed wrapper and output at dist/widget.js
  2. Resolve imports from gatsby to the gatsby-shim.js file
  3. Make React and ReactDOM externals (third-party website should have them available as globals)
  4. Use a Gatsby compatible Babel configuration

This will vary somewhat for each application, but for reference this is the webpack.config.js used in this solution:

const path = require("path")
const Dotenv = require("dotenv-webpack")

module.exports = {
  mode: "production",
  entry: "./embed/index.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "widget.js",
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            plugins: ["@babel/plugin-proposal-optional-chaining"],
            presets: [
              "@babel/preset-env",
              "@babel/preset-react",
              "@emotion/babel-preset-css-prop",
            ],
          },
        },
      },
      {
        test: /\.svg$/,
        use: ["@svgr/webpack", "url-loader"],
      },
    ],
  },
  resolve: {
    modules: [path.resolve(__dirname, "../src"), "node_modules"],
    extensions: [".js", ".jsx", ".json"],
    alias: {
      gatsby: path.resolve(__dirname, "gatsby-shim.js"),
    },
  },
  externals: {
    react: "React",
    "react-dom": "ReactDOM",
  },
  plugins: [
    new Dotenv({
      systemvars: true,
    }),
  ],
  optimization: {
    usedExports: true,
  },
  devtool: "nosources-source-map",
}

Tying it all together #

The final piece of the puzzle is orchestrating these pieces at build time. To do that, the scripts are configured as below in package.json:

{
  "scripts": {
    "prebuild:embed": "node embed/prepare-imports.js",
    "build:embed": "webpack --config embed/webpack.config.js",
    "postbuild:embed": "cp -R embed/dist/widget.* ./public/embed/",
    "build": "npm-run-all build:gatsby build:embed",
    "build:gatsby": "gatsby build",
  }
}

Since Yarn will automatically run prebuild: and postbuild: prefixed scripts, this recipe results in the following:

  1. gatsby build is executed like normal
  2. prebuild:embed is run, executing the prepare-imports script that makes the GraphQL query data available to the embed at a consistent locaiton
  3. build:embed invokes the isolated Webpack bundling of the embed itself
  4. postbuild:embed copies the output from the embed build into the Gatsby public folder for consumption by other applications

Here’s a simple example of how this embed gets used by downstream applications:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Header Embed Widget</title>
    <style>
      body {
        margin: 0;
      }
    </style>
  </head>
  <body>
    <!-- Header will be rendered inside this container -->
    <div data-render="embed-header"></div>

    <!--
      React and ReactDOM are dependencies; these tags will ensure
      they're available on the page, but they can be removed if compatible
      versions are already available.
    -->
    <script
      src="https://unpkg.com/react@16/umd/react.production.min.js"
      crossorigin
    ></script>
    <script
      src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js"
      crossorigin
    ></script>

    <!-- This loads the embed code with all other dependencies and content -->
    <script
      src="/embed/widget.js"
      defer
      crossorigin
    ></script>
  </body>
</html>

Conclusion #

It’s not as cut and dry as a lot of the tasks React and Gatsby make easy for developers, but this solution has made it possible to reuse a fully featured Gatsby-dependent React component outside of the application without requiring any additional tooling for those implementing it.

 
24
Kudos
 
24
Kudos

Now read this

Render templates from anywhere in Ruby on Rails

Rails 5 recently shipped, and among many other new features is a new renderer that makes it easy to render fully composed views outside of your controllers. This comes in handy if you want to attach, say, an HTML receipt to an order... Continue →