emotion: @emotion/server crash on import in Worker environment

Current behavior:

Looks like @emotion/server always assumes a Node.js environment. However, it is now possible to do SSR in Workers environment (such as Cloudflare Workers). This package breaks on import because some dependencies (html-tokenize) try to access Buffer, Stream, and other Node-only APIs.

I’ve tried to get some of the problems fixed: https://github.com/LinusU/buffer-from/issues/13 However, html-tokenize have other issues.

To reproduce:

Run import createEmotionServer from '@emotion/server/create-instance' in a worker or a browser environment.

It cannot be reproduced in Codesandbox because looks like they add some Node API polyfills (Buffer, etc).

Expected behavior:

It should not crash on import. More specifically, it would be great if there was an ESM build or a way to import only the necessary functions. For example, importing createExtractCriticalToChunks and createConstructStyleTagsFromChunks, and leaving out createRenderStylesToStream, which is the one causing problems and not needed for this use case.

Right now I have this code, which works in Node but not in workers.

About this issue

  • Original URL
  • State: open
  • Created 3 years ago
  • Reactions: 18
  • Comments: 19 (6 by maintainers)

Most upvoted comments

Our eventual solution was to replace @emotion/server with a “vendored” package in our monorepo.

package.json
{
  "name": "emotion-server",
  "sideEffects": false
}
index.d.ts
import type { EmotionCache } from "@emotion/utils";

interface EmotionCriticalToChunks {
  html: string;
  styles: { key: string; ids: string[]; css: string }[];
}

interface EmotionServer {
  constructStyleTagsFromChunks: (
    criticalData: EmotionCriticalToChunks
  ) => string;
  extractCriticalToChunks: (html: string) => EmotionCriticalToChunks;
}

export function createEmotionServer(cache: EmotionCache): EmotionServer;
index.js
function createExtractCriticalToChunks(cache) {
  return function (html) {
    const RGX = new RegExp(`${cache.key}-([a-zA-Z0-9-_]+)`, "gm");

    const o = { html, styles: [] };
    let match;
    const ids = {};
    while ((match = RGX.exec(html)) !== null) {
      if (ids[match[1]] === undefined) {
        ids[match[1]] = true;
      }
    }

    const regularCssIds = [];
    let regularCss = "";

    Object.keys(cache.inserted).forEach((id) => {
      if (
        (ids[id] !== undefined ||
          cache.registered[`${cache.key}-${id}`] === undefined) &&
        cache.inserted[id] !== true
      ) {
        if (cache.registered[`${cache.key}-${id}`]) {
          regularCssIds.push(id);
          regularCss += cache.inserted[id];
        } else {
          o.styles.push({
            key: `${cache.key}-global`,
            ids: [id],
            css: cache.inserted[id],
          });
        }
      }
    });

    o.styles.push({ key: cache.key, ids: regularCssIds, css: regularCss });

    return o;
  };
}

function generateStyleTag(cssKey, ids, styles, nonceString) {
  return `<style data-emotion="${cssKey} ${ids}"${nonceString}>${styles}</style>`;
}

function createConstructStyleTagsFromChunks(cache, nonceString) {
  return function (criticalData) {
    let styleTagsString = "";

    criticalData.styles.forEach((item) => {
      styleTagsString += generateStyleTag(
        item.key,
        item.ids.join(" "),
        item.css,
        nonceString
      );
    });

    return styleTagsString;
  };
}

export function createEmotionServer(cache) {
  if (cache.compat !== true) {
    cache.compat = true;
  }
  const nonceString =
    cache.nonce !== undefined ? ` nonce="${cache.nonce}"` : "";
  return {
    extractCriticalToChunks: createExtractCriticalToChunks(cache),
    constructStyleTagsFromChunks: createConstructStyleTagsFromChunks(
      cache,
      nonceString
    ),
  };
}

This exports a createEmotionServer that is a drop-in replacement for @emotion/server/create-instance in a web worker environment. It’s functionally identical to the actual package, just without the Node.js-only parts we don’t use.

For others who might need a little extra guidance and building on the work of @aaronadamsCA

  1. Create new directory /app/vendor/@emotion/server

  2. Add index.ts in this directory

/app/vendor/@emotion/server/index.ts
import type { EmotionCache } from "@emotion/utils";

function createExtractCriticalToChunks(cache: EmotionCache) {
  return function (html: string) {
    const RGX = new RegExp(`${cache.key}-([a-zA-Z0-9-_]+)`, "gm");

    const o: {
      html: string;
      styles: { key: string; ids: string[]; css: string | boolean }[];
    } = { html, styles: [] };
    let match;
    const ids: { [key: string]: boolean} = {};
    while ((match = RGX.exec(html)) !== null) {
      if (ids[match[1]] === undefined) {
        ids[match[1]] = true;
      }
    }

    const regularCssIds: string[] = [];
    let regularCss = "";

    Object.keys(cache.inserted).forEach((id) => {
      if (
        (ids[id] !== undefined ||
          cache.registered[`${cache.key}-${id}`] === undefined) &&
        cache.inserted[id] !== true
      ) {
        if (cache.registered[`${cache.key}-${id}`]) {
          regularCssIds.push(id);
          regularCss += cache.inserted[id];
        } else {
          o.styles.push({
            key: `${cache.key}-global`,
            ids: [id],
            css: cache.inserted[id],
          });
        }
      }
    });

    o.styles.push({ key: cache.key, ids: regularCssIds, css: regularCss });

    return o;
  };
}

function generateStyleTag(cssKey: string, ids: string, styles: string | boolean, nonceString: string) {
  return `<style data-emotion="${cssKey} ${ids}"${nonceString}>${styles}</style>`;
}

function createConstructStyleTagsFromChunks(cache: EmotionCache, nonceString: string) {
  return function (criticalData: ReturnType<ReturnType<typeof createExtractCriticalToChunks>>) {
    let styleTagsString = "";

    criticalData.styles.forEach((item) => {
      styleTagsString += generateStyleTag(
        item.key,
        item.ids.join(" "),
        item.css,
        nonceString
      );
    });

    return styleTagsString;
  };
}

export function createEmotionServer(cache: EmotionCache) {
  if (cache.compat !== true) {
    cache.compat = true;
  }
  const nonceString =
    cache.nonce !== undefined ? ` nonce="${cache.nonce}"` : "";
  return {
    extractCriticalToChunks: createExtractCriticalToChunks(cache),
    constructStyleTagsFromChunks: createConstructStyleTagsFromChunks(
      cache,
      nonceString
    ),
  };
}
  1. Update /app/entry.server.tsx to use the new Emotion server
/app.entry.server.tsx
//... existing imports

import createEmotionCache from '@emotion/cache';
import { createEmotionServer } from '~/vendor/@emotion/server';

export default function handleRequest(
) {
  const cache = createEmotionCache({ key: 'css' });
  const { extractCriticalToChunks } = createEmotionServer(cache);

  //... if using MUI boilerplate you might have MuiRemixServer function
  const html = ReactDOMServer.renderToString(/* existing component */);

  const { styles } = extractCriticalToChunks(html);

  //... injecting styles
  //... returns
}

@aaronadamsCA Thanks! I’m using Chakra-UI in a Remix project (specifically a Hydrogen Shopify app) and had the same issue. The workaround is very helpful.

So from my understanding currently the only workaround is by serving the content as a string with renderToString and add the emotion styles in the string. There are no workarounds if we need to use renderToReadableStream, correct?

@aaronadamsCA thanks! This works like charm!

I’d prefer not to mix ESM with require - it’s a can of worms that I prefer closed ;p

Maybe the simplest solution would be to convert these imports to dynamic import() expressions inside createRenderStylesToNodeStream():

We probably can’t do that because this would taint the whole function - it would have to become async and that would be a breaking change.

Even though we only use constructStyleTagsFromChunks and extractCriticalToChunks, we are still affected by the Node.js-only import of html-tokenize. That problem will need to be solved before any other improvements can be made.

Ah, ye - I can see that. We need to restructure our package a little bit or provide different bundles for specific environments. This might require some changes in our bundling solution (https://github.com/preconstruct/preconstruct/) to make it all work.

Yep, this is what I meant by “why isn’t @emotion/server just getting those tags from other parts of Emotion”. We’d love this.

It’s on the “roadmap”. However, lately, I don’t get as much free time for OSS as I used to have and I’m not certain when I will be able to actually work on this.

@Andarist You guessed right! html-tokenize is heavily dependent on Node.js. I’ve tried polyfilling its various needs to get it to run in a worker environment, without success; it gets upset with the absence of Buffer, and doesn’t seem to tolerate a polyfill.

html-tokenize appears to be a dead project; the last major release was in 2016 (with one dependency update in 2020). I think the only way forward here would be to get rid of that dependency; the discussion in #2781 seems to be on the right track.