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

Labels

No labels
No labels

Type

Projects

Status

In Review

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions