Skip to content

useThemeCore double-injects core <link> under StrictMode, can override variant :root tokens #261

@brian-smith-tcril

Description

@brian-smith-tcril

Note

Issue written by Claude

Summary

useThemeCore appends a new <link> to document.head on every effect run without deduplicating against an existing one. Under React StrictMode (which is enabled in the dev shell), the effect fires twice on mount, leaving a duplicate core stylesheet appended after the theme variant stylesheet. Any :root declaration that the core and variant share gets resolved to the core's value rather than the variant's.

Where

runtime/react/hooks/theme/useThemeCore.js (built output below for reference):

useEffect(() => {
  if (!themeCore?.url) {
    setIsThemeCoreComplete(true);
    return;
  }
  const themeCoreLink = document.createElement('link');
  themeCoreLink.href = themeCore.url;
  themeCoreLink.rel = 'stylesheet';
  themeCoreLink.dataset.themeCore = 'true';
  // ...
  document.head.insertAdjacentElement('beforeend', themeCoreLink);
}, [themeCore?.url, onComplete]);

No document.head.querySelector('link[data-theme-core=\"true\"]') check, and no cleanup. Compare with useThemeVariants, which dedups via document.head.querySelector(\link[href='${url}']`)` and updates the existing element instead of appending a new one.

Repro

Configure a site with a properly split core + light theme (core.min.css carrying base tokens, light.min.css carrying variant overrides). In dev (StrictMode on):

theme: {
  core:     { url: '.../core.min.css' },
  variants: { light: { url: '.../light.min.css' } },
},

Inspect <head>. You'll find:

..., <link data-theme-core>, <link data-theme-variant=\"light\">, <link data-theme-core>

The second data-theme-core link is the duplicate from the StrictMode re-mount, and it sits after the variant.

Observed impact — @edx/elm-theme@1.11.1

Using the elm theme as a concrete example: core.min.css and light.min.css both write to :root, and three variables overlap. After this bug, the trailing core link wins for all three:

variable core light (intended) resolved with bug
--pgn-size-input-btn-border-width 1px var(--pgn-size-border-width)1px equivalent
--pgn-spacing-btn-focus-gap 2px var(--pgn-size-btn-focus-width)2px equivalent
--pgn-typography-btn-font-weight 500 var(--pgn-typography-font-weight-normal)400 500 instead of 400

So with elm theme today, every button renders one weight heavier than the variant intends. Other theme packages that split tokens between core and a variant the same way will hit analogous problems on whatever variables they happen to share.

Suggested fix

Mirror what useThemeVariants already does — either:

  1. Dedup against an existing link[data-theme-core=\"true\"] and update it in place instead of appending a new one, or
  2. Return a cleanup function from the effect that removes the link, so StrictMode's discard-and-remount cycle nets to zero extra links.

(1) keeps behavior identical between dev and prod and avoids a flash where the core is briefly removed.

Metadata

Metadata

Assignees

Type

Projects

Status

Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions