-
Notifications
You must be signed in to change notification settings - Fork 5
feat(ui): prototype automatic Shadow DOM style isolation #322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5983688
7108e77
94c3718
66a5774
c2ff242
8ec3700
d8b80ed
b3761bd
8ae31a5
7a311b3
f735fcb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| '@youversion/platform-core': major | ||
| '@youversion/platform-react-hooks': major | ||
| '@youversion/platform-react-ui': major | ||
| --- | ||
|
|
||
| Prototype automatic Shadow DOM style isolation on `YouVersionAuthButton` so host-page selectors cannot override its internal styles. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| # ADR 0005: Prototype automatic Shadow DOM style isolation | ||
|
|
||
| Status: Proposed proof of concept | ||
|
|
||
| ## Problem | ||
|
|
||
| Host applications can apply unlayered global rules such as `button { ... }` or | ||
| Tailwind v3 preflight to SDK markup. Unlayered author CSS outranks the SDK's | ||
| layered CSS, so selector specificity alone cannot guarantee isolation. | ||
|
|
||
| Resets, stronger selectors, `!important`, cascade layers, and `@scope` all | ||
| continue participating in the host document's cascade. They can reduce | ||
| accidental conflicts but cannot prevent an outside selector from matching SDK | ||
| internals. Shadow DOM was selected because it creates a browser-enforced | ||
| selector boundary. | ||
|
|
||
| ## Prototype | ||
|
|
||
| `YouVersionAuthButton` automatically creates an open shadow root and renders its | ||
| existing implementation inside it through a React portal. The SDK's compiled | ||
| Tailwind CSS—generated from `src/styles/global.css` and embedded as | ||
| `__YV_STYLES__`—is installed inside that root. Consumers continue to write | ||
| `<YouVersionAuthButton />`; isolation is not an option they must discover or | ||
| enable. | ||
|
|
||
| This PR intentionally applies the architecture to one representative component. | ||
| It asks whether automatic Shadow DOM boundaries are the right foundation before | ||
| the same pattern is rolled out across the UI package. | ||
|
|
||
| The constructable stylesheet is cached per owner `Document`, because a sheet | ||
| created in the top-level document cannot be adopted by a shadow root rendered in | ||
| a same-origin iframe. Browsers without constructable stylesheets receive a | ||
| `<style>` element in the root. The light-DOM host gets an inline-important box | ||
| reset; an inner, unreachable wrapper resets inherited standard properties. | ||
| The shadow stylesheet also suppresses `::before` and `::after` on the | ||
| light-DOM host with shadow-context important declarations, preventing host-page | ||
| CSS from injecting generated content around the isolated component. | ||
| Because that reset removes the light-DOM font inheritance the button previously | ||
| relied on, the implementation now applies its intended `font-sans` utility | ||
| explicitly. | ||
|
|
||
| The Vite example includes a Hostile CSS page with a light-DOM positive control | ||
| beside the isolated SDK button. It demonstrates type selectors, inherited | ||
| properties, universal `!important` rules, direct shadow-host attacks, and a known | ||
| document-wide `@font-face` limitation. | ||
|
|
||
| ## What this proves | ||
|
|
||
| - Ordinary and `!important` host selectors cannot select the button internals. | ||
| - Isolation is automatic without changing the component's React props API. | ||
| - Existing click behavior continues to work through the React portal. | ||
| - Strict Mode does not attach the root twice. | ||
| - Constructed stylesheets are created per owner document, so mounting the shadow | ||
| host in a same-origin iframe does not cause a cross-document adoption error. | ||
|
|
||
| Focused Chromium stories verify hostile button rules, host-generated | ||
| pseudo-content, existing interactions, and mounting in a same-origin iframe. | ||
| Unit tests verify Strict Mode behavior and the inline-important host reset. The | ||
| remaining hostile vectors are available for manual inspection on the demo page. | ||
|
|
||
| ## Compatibility impact | ||
|
|
||
| Although the React props API is unchanged, the rendered DOM structure is not. | ||
| Consumers that query or style internal light-DOM markup must instead account for | ||
| the shadow root. Because the prototype attaches the shadow root in `useEffect`, | ||
| server output contains an empty host. The button appears after hydration, and | ||
| its forwarded ref becomes available later than it did previously. This is | ||
| therefore represented as a breaking change rather than an implementation | ||
| detail. | ||
|
Comment on lines
+61
to
+69
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: Good to know. It would be a breaking change. |
||
|
|
||
| ## Deliberately deferred | ||
|
|
||
| - Rollout to all exported components. | ||
| - Radix popover/dialog portal placement and focus management. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: Yeah, this one is a big one. Would it still work in the context of the popover and dialog with portals?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Appreciate the callout in https://www.youtube.com/watch?v=MdSEjq49CMk |
||
| - Form association when controls live outside their form's tree scope. | ||
| - SSR/hydration and the first client paint. | ||
| - A package-wide custom-property audit. `all: initial` does not reset custom | ||
| properties; the larger investigation branch tested redeclaring Tailwind v4's | ||
| generated theme tokens on the protected internal wrapper. | ||
| - A deliberate inheritance policy for writing direction and future custom | ||
| properties. Some host values may be intentional localization inputs, while | ||
| SDK-owned visual tokens need shadow-local defaults. | ||
| - Host `@font-face` rules, which are not scoped by Shadow DOM. | ||
| - Ancestor layout constraints, which Shadow DOM cannot isolate. | ||
| - Event retargeting, nested-root behavior, and a supported consumer customization | ||
| model. | ||
| - Stylesheet construction/adoption failure recovery beyond feature fallback. | ||
| - A full browser and assistive-technology matrix; current browser verification is | ||
| Chromium-focused. | ||
| - Consumer test-query migration guidance and a component-by-component rollout. | ||
|
Comment on lines
+71
to
+90
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. praise: I appreciate seeing this deferred section. The communication is helpful |
||
|
|
||
| This prototype should be reviewed as an architectural checkpoint, not as a | ||
| complete solution ready for release. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was a great demo in https://www.youtube.com/watch?v=MdSEjq49CMk! |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,199 @@ | ||
| import { useState } from 'react'; | ||
| import { YouVersionAuthButton } from '@youversion/platform-react-ui'; | ||
|
|
||
| interface HostileVector { | ||
| key: string; | ||
| label: string; | ||
| expectation: string; | ||
| example: string; | ||
| css: string; | ||
| } | ||
|
|
||
| const HOSTILE_VECTORS: HostileVector[] = [ | ||
| { | ||
| key: 'type-selectors', | ||
| label: 'Type selectors', | ||
| expectation: 'The plain button changes; the SDK button should not.', | ||
| example: 'button { … }', | ||
| css: ` | ||
| .hostile-zone button, | ||
| .hostile-zone [role='button'] { | ||
| appearance: none !important; | ||
| background: #b91c1c !important; | ||
| border: 8px dashed #84cc16 !important; | ||
| border-radius: 0 !important; | ||
| color: #fff !important; | ||
| font: 28px/1 fantasy !important; | ||
| padding: 28px !important; | ||
| text-transform: uppercase !important; | ||
| }`, | ||
| }, | ||
| { | ||
| key: 'inherited', | ||
| label: 'Inherited properties', | ||
| expectation: 'Host text changes; the SDK button should retain its typography.', | ||
| example: '.hostile-zone { font-family: … }', | ||
| css: ` | ||
| .hostile-zone { | ||
| color: #d600d6 !important; | ||
| cursor: crosshair !important; | ||
| font-family: 'Comic Sans MS', fantasy !important; | ||
| font-size: 24px !important; | ||
| font-style: italic !important; | ||
| letter-spacing: 0.25em !important; | ||
| line-height: 2.4 !important; | ||
| text-transform: uppercase !important; | ||
| }`, | ||
| }, | ||
| { | ||
| key: 'universal-important', | ||
| label: 'Universal selector with !important', | ||
| expectation: 'Every reachable host element changes; shadow internals should not.', | ||
| example: '* { … !important }', | ||
| css: ` | ||
| .hostile-zone * { | ||
| color: #d600d6 !important; | ||
| font-family: 'Comic Sans MS', fantasy !important; | ||
| letter-spacing: 0.2em !important; | ||
| text-transform: uppercase !important; | ||
| }`, | ||
| }, | ||
| { | ||
| key: 'shadow-host', | ||
| label: 'Shadow-host box attack', | ||
| expectation: 'The witness disappears; the SDK host should remain usable.', | ||
| example: '[data-yv-shadow-host] { display: none !important }', | ||
| css: ` | ||
| .hostile-zone [data-yv-shadow-host], | ||
| .hostile-zone [data-host-box-witness] { | ||
| display: none !important; | ||
| opacity: 0 !important; | ||
| pointer-events: none !important; | ||
| transform: scale(0.5) !important; | ||
| }`, | ||
| }, | ||
| { | ||
| key: 'host-pseudo-elements', | ||
| label: 'Shadow-host pseudo-elements', | ||
| expectation: 'The witness gains generated content; the SDK host should not.', | ||
| example: '[data-yv-shadow-host]::before { content: … !important }', | ||
| css: ` | ||
| .hostile-zone [data-yv-shadow-host]::before, | ||
| .hostile-zone [data-yv-shadow-host]::after, | ||
| .hostile-zone [data-host-pseudo-witness]::before { | ||
| content: 'HOSTILE' !important; | ||
| display: block !important; | ||
| background: #b91c1c !important; | ||
| color: #fff !important; | ||
| padding: 8px !important; | ||
| }`, | ||
| }, | ||
| { | ||
| key: 'font-face', | ||
| label: '@font-face family collision (known limitation)', | ||
| expectation: | ||
| 'Registers a document-level Inter collision that can reach the shadow tree; the visible result depends on locally installed fonts.', | ||
| example: "@font-face { font-family: 'Inter'; … }", | ||
| css: ` | ||
| @font-face { | ||
| font-family: 'Inter'; | ||
| src: local('Comic Sans MS'), local('Chalkboard SE'); | ||
| } | ||
| @font-face { | ||
| font-family: 'Untitled Serif'; | ||
| src: local('Comic Sans MS'), local('Chalkboard SE'); | ||
| }`, | ||
| }, | ||
| ]; | ||
|
|
||
| export function HostileCssPage() { | ||
| const [enabled, setEnabled] = useState<Record<string, boolean>>({ | ||
| 'type-selectors': true, | ||
| inherited: false, | ||
| 'universal-important': false, | ||
| 'shadow-host': false, | ||
| 'host-pseudo-elements': false, | ||
| 'font-face': false, | ||
| }); | ||
|
|
||
| const toggle = (key: string) => { | ||
| setEnabled((current) => ({ ...current, [key]: !current[key] })); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-8 p-6 md:p-12"> | ||
| {HOSTILE_VECTORS.filter((vector) => enabled[vector.key]).map((vector) => ( | ||
| <style key={vector.key}>{vector.css}</style> | ||
| ))} | ||
|
|
||
| <section className="rounded-lg border p-5"> | ||
| <h1 className="text-xl font-semibold">Automatic Shadow DOM isolation POC</h1> | ||
| <p className="mt-2 max-w-3xl text-sm text-muted-foreground"> | ||
| This branch automatically isolates only <code>YouVersionAuthButton</code>. The plain host | ||
| controls are positive witnesses: they should look broken when an attack is active, while | ||
| the SDK button should remain stable. The font-face option demonstrates a known Shadow DOM | ||
| limitation. | ||
| </p> | ||
|
|
||
| <fieldset className="mt-5 flex flex-col gap-3"> | ||
| <legend className="mb-2 font-medium">Hostile stylesheet vectors</legend> | ||
| {HOSTILE_VECTORS.map((vector) => ( | ||
| <div | ||
| key={vector.key} | ||
| className="flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between" | ||
| > | ||
| <label className="flex items-start gap-3 text-sm"> | ||
| <input | ||
| className="mt-1" | ||
| type="checkbox" | ||
| checked={enabled[vector.key] ?? false} | ||
| onChange={() => toggle(vector.key)} | ||
| /> | ||
| <span> | ||
| <span className="font-medium">{vector.label}</span> | ||
| <span className="block text-muted-foreground">{vector.expectation}</span> | ||
| </span> | ||
| </label> | ||
| <code className="w-fit max-w-full overflow-x-auto whitespace-nowrap rounded bg-muted px-2 py-1 text-xs"> | ||
| {vector.example} | ||
| </code> | ||
| </div> | ||
| ))} | ||
| </fieldset> | ||
| </section> | ||
|
|
||
| <div className="grid gap-8 md:grid-cols-2"> | ||
| <section className="flex flex-col gap-5 rounded-lg border border-dashed p-5"> | ||
| <h2 className="text-sm font-semibold">LIGHT DOM — SHOULD BE AFFECTED</h2> | ||
| <div className="hostile-zone flex flex-col gap-5"> | ||
| <button type="button">Plain host-app button</button> | ||
| <p>Plain host text for inherited-property attacks.</p> | ||
| <div data-host-box-witness className="rounded border p-3"> | ||
| Host-box witness — this should disappear during the host attack. | ||
| </div> | ||
| <div data-host-pseudo-witness className="rounded border p-3"> | ||
| Pseudo-element witness — generated content should appear above this text. | ||
| </div> | ||
| <p style={{ fontFamily: 'Inter, sans-serif' }}> | ||
| Host text requesting Inter for the font-face collision. | ||
| </p> | ||
| </div> | ||
| </section> | ||
|
|
||
| <section className="flex flex-col gap-5 rounded-lg border p-5"> | ||
| <h2 className="text-sm font-semibold">SDK POC — SHOULD RESIST</h2> | ||
| <div className="hostile-zone"> | ||
| <YouVersionAuthButton | ||
| size="short" | ||
| onAuthError={(error) => console.error('Auth error:', error)} | ||
| /> | ||
| </div> | ||
| <p className="text-sm text-muted-foreground"> | ||
| Other SDK components are intentionally absent: automatic isolation has not been rolled | ||
| out to them on this POC branch. | ||
| </p> | ||
| </section> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
praise: For dev experience, I like backwards compatability. Looks like this is not going to bork everyone's pre-existing code