Inject styles into the closest ShadowRoot when mounted inside one#232
Open
omgovich wants to merge 8 commits into
Open
Inject styles into the closest ShadowRoot when mounted inside one#232omgovich wants to merge 8 commits into
omgovich wants to merge 8 commits into
Conversation
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
|
Size Change: +54 B (+1.11%) Total Size: 4.91 kB 📦 View Changed
|
There was a problem hiding this comment.
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
useStyleSheetto detect the closest root viagetRootNode()and inject styles into eitherDocument.heador theShadowRoot. - 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 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>; |
| ## 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes the long-standing issue where
react-colorfulrenders unstyled inside Shadow DOM: the picker's<style>was always appended todocument.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
ShadowRootif the picker lives inside one, otherwise theDocument— 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.tsKey decisions:
Node.getRootNode()— the standard DOM API for "the topmost containing root of this node." Returns theShadowRootif the picker is in one, theDocumentotherwise. Cleaner and faster than a hand-rolledparentNodewalk, and used by Lit, Stencil, Floating UI, Radix, etc."head" in root/"host" in rootdiscriminators instead ofinstanceof ShadowRoot. Document has.head; ShadowRoot has.host; a stray Element has neither. This avoids referencing theShadowRootglobal at runtime (required for IE11, whereShadowRootisundefined) and guards againstgetRootNode()returning an unexpected ancestor for disconnected nodes.getRootNode: ternary usesnode.ownerDocumentwhengetRootNodeis unavailable. IE11 has no Shadow DOM anyway, so the fallback path is byte-for-byte equivalent to the old behavior.target.ownerDocument || document), not the globaldocument— this preserves iframe-embedding support (added in8681e3c).WeakMapper-root cache (wasMap<Document, …>). Keyed byDocument | ShadowRoot, so each shadow tree gets its own single injection.WeakMaplets 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.hasand.setas a statement, neither of which is impacted by IE11's WeakMap caveats).Tests
tests/shadowDom.test.js— rendersHexColorPickerinside an open shadow root and asserts a<style>lands inside the shadow tree. Tracks created hosts and removes them inafterEachso nothing leaks into other tests. Runs synchronously (nosleep) sinceuseStyleSheetusesuseLayoutEffect.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 optionalshadowprop (parallel to existingframe).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:
getRootNode()→Document→<style>appended todocument.head. Identical to master.getRootNode()→ iframe'sDocument→ style created from and appended into iframe. Identical to master.getRootNodeundefined → falls back tonode.ownerDocument→ same Document path as above.ShadowRootis referenced only as a TS type, never at runtime.WeakMapis supported in IE11 for the operations we use. Identical to master.getRootNode()may return an arbitrary Element; the runtime"head"/"host"check rejects it and we fall back tonode.ownerDocument.getRootNode()→ShadowRoot→ style appended into shadow. Previously broken, now works.The cache key is the same
Documentinstance in all non-shadow cases, so dedupe semantics (one<style>per Document) are preserved.Differences from #181
getOwnerDocumentparent-walk with the standardNode.getRootNode()API."head" / "host" in rootfor type discrimination instead ofinstanceof ShadowRoot— safe on IE11, and rejects unexpected ancestors.document.createElement(globaldocument) was used instead of the owning document, which would break iframe embedding.eslint-disable no-extra-boolean-castpragma and the redundanttypeof parentDocument !== "undefined"check that's never reachable.Bundle size
Net change reported by
compressed-size-action: +29 B (+0.6%), total 4.88 kB. Well within the per-pickersize-limitbudget.Test plan
npm test— 64/64 passing (was 63, +1 new shadow-DOM test)npm run lint— cleannpm run check-types— no errors from project code (pre-existinggoober→csstypepeer-dep warnings unrelated to this change)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