emotion icon indicating copy to clipboard operation
emotion copied to clipboard

Relax the rules for emotion cache key name

Open disintegrator opened this issue 4 years ago • 1 comments

The problem

I'm building a documentation site for our in-house UI library and one feature of the site is the ability to display live, editable examples with react-live within responsive viewports. This is simulated using iframes with the help of react-frame-component. In order to get these examples rendering correctly in iframes, I had to create separate instances of emotion's cache per viewport. Each of these instances needed a unique key. The challenge is that there aren't any ID generating libraries that can:

  • be restricted to generating ids matching /[^a-z-]/ (source). The libraries I've come across generate alphanumeric or purely numeric ids
  • work in an isomorphic React app
image

Proposed solution

It would be great if we can revise the regular expression for the cache key to allow numbers in non-leading positions. This would allow me to use libraries like @reach/auto-id or @react-aria/utils (import {useId} from '@react-aria/utils').

I propose the cache key is validated using this regex:

/[a-z][a-z0-9-]*/

This makes it a lot easier to sandbox the emotion runtime inside an iframe:

import weakMemoize from "@emotion/weak-memoize";
import createCache from "@emotion/cache";
import { CacheProvider } from "@emotion/react";
import { useId } from "@reach/auto-id";
import Frame, { FrameContextConsumer } from "react-frame-component";

export const LivePreview = (props) => {

  // 👇👇👇
  const cacheKeySuffix = useId();
  const cacheKey = `sandbox-${cacheKeySuffix}`
  const iframeInitialContent = initialContent(cacheKey);
  // 👆👆👆

  if (!cacheKeySuffix) {
    return null;
  }

  return (
    <Frame
      title="Live demo sandbox"
      initialContent={iframeInitialContent}
    >
      <FrameContextConsumer>
        {({ document }) => (
          <CacheProvider value={memoizedCreateCache(document.head)}>
            {props.children}
          </CacheProvider>
        )}
      </FrameContextConsumer>
    </Frame>
  );
};

const initialContent = (cacheKey) => `
<!DOCTYPE html>
<html>
  <!-- 👇👇👇 STORE THE CACHE KEY HERE -->
  <head data-emotion-cache-key="${cacheKey}">
  <!-- 👆👆👆 -->
    <title>Interactive code demo</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
  </head>
  <body>
    <div></div>
  </body>
</html>
`;

const memoizedCreateCache = weakMemoize((container: HTMLElement) => {
  return createCache({
    container,
    // 👇👇👇 PULL THE CACHE KEY HERE
    key: container.getAttribute("data-emotion-cache-key") || "",
  });
});

Alternative solutions

My current solution involved copying the source of @reach/auto-id and modifying to encode its numeric ID to the character range 'abcdefghij' then return that. At this point, I have a hook I can use to generate emotion cache keys.

Additional context

  • Allowed characters in identifiers: https://www.w3.org/TR/CSS21/syndata.html#characters

  • My useAlphaId variation of the useId hook from @reach/auto-id

import * as React from "react";
import { useIsomorphicLayoutEffect } from "@reach/utils";

const ID_CHARACTERS = "abcdefghij";

let serverHandoffComplete = false;

let counter = 0;
const genId = () => {
  const nextId = `${counter}`
    .split("")
    .map((digit) => {
      const i = parseInt(digit, 10);
      return ID_CHARACTERS[i];
    })
    .join("");

  counter += 1;

  return nextId;
};

/**
 * useAlphaId
 *
 * This utility hook is used to generate a unique emotion cache key which needs
 * to contain letters and dashes only.
 *
 * Note 1:
 * This code is a modified version @reach/auto-id.
 * See: https://github.com/reach/reach-ui/blob/develop/packages/auto-id/src/index.tsx
 *
 * Note 2:
 * The returned ID will initially be `null` and will update after a component
 * mounts. Users may need to supply their own ID if they need consistent values
 * for SSR.
 *
 * @see Docs https://reach.tech/auto-id
 */
export default function useAlphaId(
  idFromProps?: string | null
): string | undefined {
  /*
   * If this instance isn't part of the initial render, we don't have to do the
   * double render/patch-up dance. We can just generate the ID and return it.
   */
  const initialId = idFromProps || (serverHandoffComplete ? genId() : null);

  const [id, setId] = React.useState(initialId);

  useIsomorphicLayoutEffect(() => {
    if (id === null) {
      /*
       * Patch the ID after render. We do this in `useLayoutEffect` to avoid any
       * rendering flicker, though it'll make the first render slower (unlikely
       * to matter, but you're welcome to measure your app and let us know if
       * it's a problem).
       */
      setId(genId());
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  React.useEffect(() => {
    if (serverHandoffComplete === false) {
      /*
       * Flag all future uses of `useId` to skip the update dance. This is in
       * `useEffect` because it goes after `useLayoutEffect`, ensuring we don't
       * accidentally bail out of the patch-up dance prematurely.
       */
      serverHandoffComplete = true;
    }
  }, []);
  return id != null ? String(id) : undefined;
}

disintegrator avatar Feb 12 '21 17:02 disintegrator

I wouldn't be opposed to it but:

  • a PR for this would have to be provided by you or somebody else from the community
  • we'd have to make sure that this doesn't break SSR anyhow (for example some regexps in there might become incorrect after the proposed change)

Andarist avatar May 13 '21 10:05 Andarist