Skip to content

Inject styles into the closest ShadowRoot when mounted inside one#232

Open
omgovich wants to merge 8 commits into
masterfrom
fix/shadow-dom-styles
Open

Inject styles into the closest ShadowRoot when mounted inside one#232
omgovich wants to merge 8 commits into
masterfrom
fix/shadow-dom-styles

Conversation

@omgovich

@omgovich omgovich commented May 22, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the long-standing issue where react-colorful renders unstyled inside Shadow DOM: the picker's <style> was always appended to document.head, which is outside the shadow tree and therefore invisible to encapsulated content.

The hook now discovers the closest root of the picker element — a ShadowRoot if the picker lives inside one, otherwise the Document — and injects styles there.

This is an alternative take on #181 (same goal, different implementation — see "Differences from #181" below).

What changed

src/hooks/useStyleSheet.ts

const raw = node.getRootNode ? node.getRootNode() : node.ownerDocument;
const root = (raw && ("head" in raw || "host" in raw) ? raw : node.ownerDocument) as Root;
if (styleElementMap.has(root)) return;

const target = "head" in root ? root.head : root;
const styleElement = (target.ownerDocument || document).createElement("style");
// …nonce…
styleElementMap.set(root, styleElement);
target.appendChild(styleElement);

Key decisions:

  1. Node.getRootNode() — the standard DOM API for "the topmost containing root of this node." Returns the ShadowRoot if the picker is in one, the Document otherwise. Cleaner and faster than a hand-rolled parentNode walk, and used by Lit, Stencil, Floating UI, Radix, etc.
  2. "head" in root / "host" in root discriminators instead of instanceof ShadowRoot. Document has .head; ShadowRoot has .host; a stray Element has neither. This avoids referencing the ShadowRoot global at runtime (required for IE11, where ShadowRoot is undefined) and guards against getRootNode() returning an unexpected ancestor for disconnected nodes.
  3. IE11 fallback for getRootNode: ternary uses node.ownerDocument when getRootNode is unavailable. IE11 has no Shadow DOM anyway, so the fallback path is byte-for-byte equivalent to the old behavior.
  4. Style element is created on the owning Document (target.ownerDocument || document), not the global document — this preserves iframe-embedding support (added in 8681e3c).
  5. WeakMap per-root cache (was Map<Document, …>). Keyed by Document | ShadowRoot, so each shadow tree gets its own single injection. WeakMap lets detached shadow roots / iframe documents be garbage-collected once they go out of scope — relevant for Storybook stories, micro-frontends, or any host that creates/destroys shadow trees dynamically. Verified IE11-safe (we only use .has and .set as a statement, neither of which is impacted by IE11's WeakMap caveats).

Tests

  • tests/shadowDom.test.js — renders HexColorPicker inside an open shadow root and asserts a <style> lands inside the shadow tree. Tracks created hosts and removes them in afterEach so nothing leaks into other tests. Runs synchronously (no sleep) since useStyleSheet uses useLayoutEffect.
  • All 63 existing tests continue to pass unchanged.

Demo

  • demo/src/components/ShadowDom.tsx — small wrapper that attaches a shadow root via ref callback and portals children into it.
  • demo/src/components/PickerPreview.tsx — new optional shadow prop (parallel to existing frame).
  • demo/src/components/DevTools.tsx — adds an "RGB (Shadow DOM)" preview at the bottom so the dev harness exercises the new path alongside the existing iframe one.

Backwards compatibility

Verified path-by-path that nothing changes for existing users:

Environment Result
Modern browser, standard mount getRootNode()Document<style> appended to document.head. Identical to master.
Iframe embedding getRootNode() → iframe's Document → style created from and appended into iframe. Identical to master.
IE11 getRootNode undefined → falls back to node.ownerDocument → same Document path as above. ShadowRoot is referenced only as a TS type, never at runtime. WeakMap is supported in IE11 for the operations we use. Identical to master.
Disconnected node (edge case) getRootNode() may return an arbitrary Element; the runtime "head"/"host" check rejects it and we fall back to node.ownerDocument.
Shadow DOM (new) getRootNode()ShadowRoot → style appended into shadow. Previously broken, now works.

The cache key is the same Document instance in all non-shadow cases, so dedupe semantics (one <style> per Document) are preserved.

Differences from #181

  • Replaces the custom getOwnerDocument parent-walk with the standard Node.getRootNode() API.
  • Uses "head" / "host" in root for type discrimination instead of instanceof ShadowRoot — safe on IE11, and rejects unexpected ancestors.
  • Fixes a regression in fix attaching styles, check shadowroot #181 where document.createElement (global document) was used instead of the owning document, which would break iframe embedding.
  • Removes the dead eslint-disable no-extra-boolean-cast pragma and the redundant typeof parentDocument !== "undefined" check that's never reachable.
  • No new utility file; the logic is inline (~10 lines of net change).

Bundle size

Net change reported by compressed-size-action: +29 B (+0.6%), total 4.88 kB. Well within the per-picker size-limit budget.

Test plan

  • npm test — 64/64 passing (was 63, +1 new shadow-DOM test)
  • npm run lint — clean
  • npm run check-types — no errors from project code (pre-existing goobercsstype peer-dep warnings unrelated to this change)
  • Manual: ran npm run start-demo, confirmed the new "RGB (Shadow DOM)" preview renders fully styled and that the <style> element lives inside #shadow-root (open) per DevTools
  • Manual: existing iframe preview still works unchanged

Use Node.getRootNode() to discover the surrounding Document or
ShadowRoot and inject the picker's <style> there. This fixes the
long-standing issue where react-colorful renders unstyled inside
Shadow DOM because document.head is outside the shadow tree.

- Falls back to ownerDocument on IE11 (no getRootNode, no Shadow DOM)
- Uses `"head" in root` to discriminate without referencing the
  ShadowRoot global at runtime (IE11-safe)
- Per-root <style> cache (Map<Document | ShadowRoot, HTMLStyleElement>)
- Nonce and iframe behavior preserved
- Adds a Shadow DOM preview to the dev DevTools harness
@github-actions

github-actions Bot commented May 22, 2026

Copy link
Copy Markdown

Size Change: +54 B (+1.11%)

Total Size: 4.91 kB

📦 View Changed
Filename Size Change
dist/index.module.js 4.91 kB +54 B (+1.11%)

compressed-size-action

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates react-colorful’s stylesheet injection so that when a picker is mounted inside a Shadow DOM tree, its <style> tag is injected into the closest ShadowRoot (instead of always document.head), restoring styling under shadow encapsulation while preserving iframe/document behavior.

Changes:

  • Update useStyleSheet to detect the closest root via getRootNode() and inject styles into either Document.head or the ShadowRoot.
  • Add a Shadow DOM-focused test and extend the demo harness to exercise the Shadow DOM mounting path.
  • Update bundle-size documentation/limits metadata (README/package.json/CLAUDE.md).

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/hooks/useStyleSheet.ts Inject styles into the closest root (Document or ShadowRoot) and cache per-root.
tests/shadowDom.test.js Add a regression test asserting styles are injected into an open shadow root.
demo/src/components/ShadowDom.tsx Add a demo helper that mounts children into a shadow root via a portal.
demo/src/components/PickerPreview.tsx Add shadow option to preview wrapper selection.
demo/src/components/DevTools.tsx Add a “RGB (Shadow DOM)” preview to the demo devtools.
README.md Update advertised gzipped size/lighter-than comparison numbers.
package.json Update package description size text and adjust one size-limit entry.
CLAUDE.md Update internal bundle-size guidance/figures.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/hooks/useStyleSheet.ts Outdated
Comment thread src/hooks/useStyleSheet.ts Outdated
Comment thread src/hooks/useStyleSheet.ts Outdated
Comment thread tests/shadowDom.test.js
Comment thread tests/shadowDom.test.js Outdated
Comment thread CLAUDE.md

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment thread src/hooks/useStyleSheet.ts Outdated
Comment thread CLAUDE.md

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment on lines +10 to +17

const hostRef = useCallback((host: HTMLDivElement | null) => {
if (host && !host.shadowRoot) {
setRoot(host.attachShadow({ mode: "open" }));
}
}, []);

return <div ref={hostRef}>{root && ReactDOM.createPortal(children, root)}</div>;
Comment thread CLAUDE.md
## Key constraint: bundle size

Every picker must stay under 3 KB gzipped (enforced by `size-limit` in package.json). This shapes all code decisions: `Object.assign` over spread (smaller output), keyCodes over key strings, no dependencies, manually optimized algorithms. Always run `npm run size` after changes that add code.
Every picker must stay under 3.1 KB gzipped (enforced by `size-limit` in package.json). This shapes all code decisions: `Object.assign` over spread (smaller output), keyCodes over key strings, no dependencies, manually optimized algorithms. Always run `npm run size` after changes that add code.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants