Skip to content

feat(ui): prototype automatic Shadow DOM style isolation - #322

Closed
abharms wants to merge 11 commits into
youversion:journey-to-the-shadow-domfrom
abharms:poc/shadow-dom-style-isolation
Closed

feat(ui): prototype automatic Shadow DOM style isolation#322
abharms wants to merge 11 commits into
youversion:journey-to-the-shadow-domfrom
abharms:poc/shadow-dom-style-isolation

Conversation

@abharms

@abharms abharms commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Note

This is an architectural proof of concept on one representative component,
not a package-wide rollout.

Summary

Host applications can apply global CSS—Tailwind preflight or even a plain
button { ... } rule—to React SDK component internals and substantially change
their appearance.

This PR prototypes automatic Shadow DOM isolation on
YouVersionAuthButton. Consumers continue rendering the component normally,
with no wrapper or opt-in.

Shadow DOM was selected because stronger selectors, resets, !important,
cascade layers, and @scope still participate in the host document's cascade.
They can reduce conflicts, but they cannot prevent outside selectors from
matching SDK internals.

Changes

  • Renders the existing Auth Button implementation through an internal Shadow
    Root while preserving its props and forwarded ref.
  • Installs the compiled SDK stylesheet inside the Shadow Root.
  • Caches constructable stylesheets per owner document, with a <style> fallback.
  • Resets inherited styles and protects the light-DOM host from direct box and
    pseudo-element attacks.
  • Adds focused unit and Chromium coverage for hostile CSS, Strict Mode,
    existing interactions, and same-origin iframe mounting.
  • Adds a Hostile CSS page to the Vite example with affected light-DOM
    controls beside the isolated SDK button.
  • Records the approach and deferred work in
    ADR 0005.

Start here: run the Vite example and open Hostile CSS. The light-DOM
controls should visibly break while the SDK button remains stable.

What this proves

For YouVersionAuthButton, the POC guards against the principal host-author CSS
vectors:

  • Element and universal selectors
  • Ordinary inherited styles
  • !important declarations
  • Direct Shadow host styling
  • Host ::before and ::after generated content

The @font-face checkbox demonstrates the primary exception: font-family
registrations remain document-wide and are not isolated by Shadow DOM.

Known limitations

Although the React props API is unchanged, the rendered DOM structure changes.
Consumer queries, automation, SSR behavior, ref timing, and native event targets
may be affected. A package-wide rollout also requires validation of forms,
portals, focus, accessibility, custom properties, performance, and additional
browsers.

This PR asks whether Shadow DOM is the right architectural foundation before
expanding the approach to other components.

Test plan

Passed locally:

  • pnpm lint
  • pnpm typecheck
  • Core tests: 369 passed
  • React hooks tests: 289 passed
  • Focused Shadow Root unit tests: 2 passed
  • Focused Chromium Shadow DOM stories: 2 passed
  • UI build and generated-style verification
  • Vite example production build

The combined UI integration run completed 449 of 450 tests. One unrelated
Bible Reader test exceeded its five-second timeout under full-suite concurrency;
that complete file passes 33/33 when run independently.

Greptile Summary

The PR prototypes automatic Shadow DOM style isolation for YouVersionAuthButton.

  • Wraps the existing implementation in an internal shadow-root host while preserving its props and forwarded ref.
  • Installs the compiled SDK stylesheet using document-scoped constructable stylesheets with a <style> fallback.
  • Adds hostile-CSS, Strict Mode, interaction, and same-origin iframe coverage.
  • Adds a Vite demonstration page and documents compatibility constraints and deferred work in ADR 0005.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
packages/ui/src/lib/shadow-root-host.tsx Introduces the shadow-root lifecycle, host reset, per-document constructable stylesheet cache, fallback stylesheet, and React portal boundary.
packages/ui/src/lib/shadow-isolation.tsx Adds a ref-preserving higher-order wrapper that renders component implementations through ShadowRootHost.
packages/ui/src/components/YouVersionAuthButton.tsx Applies automatic isolation to the public auth button and explicitly pins its intended sans-serif typography.
packages/ui/src/styles/global.css Adds shadow-context rules suppressing generated pseudo-element content on the selectable light-DOM host.
packages/ui/src/lib/shadow-root-host.test.tsx Covers Strict Mode attachment, host reset declarations, and fallback stylesheet identity.
packages/ui/src/components/YouVersionAuthButton.shadow-isolation.stories.tsx Adds Chromium coverage for hostile global CSS and same-origin iframe stylesheet adoption.
docs/adr/0005-prototype-shadow-dom-style-isolation.md Records the prototype design, compatibility impact, validated behavior, and deliberately deferred rollout concerns.

Sequence Diagram

sequenceDiagram
  participant Consumer
  participant Button as YouVersionAuthButton
  participant Wrapper as withShadowIsolation
  participant Host as ShadowRootHost
  participant Shadow as ShadowRoot
  participant Impl as YouVersionAuthButtonImpl

  Consumer->>Button: Render props and forwarded ref
  Button->>Wrapper: Render isolated component
  Wrapper->>Host: Create light-DOM host
  Host->>Shadow: Attach open shadow root after mount
  Host->>Shadow: Install compiled SDK stylesheet
  Host->>Shadow: Portal implementation
  Shadow->>Impl: Render native button
  Impl-->>Consumer: Forward HTMLButtonElement ref
Loading

Reviews (2): Last reviewed commit: "fix(ui): use style precedence for shadow..." | Re-trigger Greptile

Context used:

@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f735fcb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@youversion/platform-core Major
@youversion/platform-react-hooks Major
@youversion/platform-react-ui Major
vite-react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@mic-mart mic-mart left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code review — all clear

No blocking issues. This is a well-scoped, well-documented Shadow DOM isolation POC: the shadow-root attach + StrictMode single-attach guard, the per-document constructable-stylesheet cache, the <style> fallback path, and ref forwarding through the HOC all check out. No functional bugs and no CLAUDE.md violations were found, and the known limitations (empty SSR host, post-hydration ref timing, @font-face leakage) are correctly captured in ADR 0005 and reflected in the major changeset.

The three inline notes below are informational only. Each scored low confidence/impact in review and none should block this PR — they're recorded for the eventual package-wide rollout.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

For !important declarations on a shadow host, the shadow-tree declaration
outranks an outer author declaration by design. */
:host::before,
:host::after {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Informational only — low confidence, non-blocking.

This :host::before/::after block is added outside any @layer, unlike every other SDK rule in this file (which live in @layer yv-sdk-*, per the file header and commit 694325f).

Caveat: there is no functional impact. :host only matches inside a shadow tree, and the shadow-context !important already outranks outer author CSS regardless of layer placement, so nothing leaks into the consumer cascade. This is purely a convention nit — if you want to keep the file's layer discipline consistent you could add a short comment marking it a deliberate exception, but it is safe as-is.

data-yv-theme={theme}
className={cn(
'yv:shadow-none yv:p-3 yv:h-auto yv:w-fit',
'yv:font-sans yv:shadow-none yv:p-3 yv:h-auto yv:w-fit',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Informational only — low confidence, non-blocking.

The added yv:font-sans (here and at L239) is redundant: the SDK preflight in packages/core/src/styles/theme.css already applies font-family: var(--yv-font-sans) to every :where([data-yv-sdk]) *, and that preflight ships inside __YV_STYLES__, which is adopted into the shadow root — so the button's font was never relying on light-DOM inheritance.

Caveat: the class is harmless and renders correctly — this is not a bug. The only real nit is that ADR 0005's rationale ("the reset removes the light-DOM font inheritance the button previously relied on") slightly misreads the mechanism. No code change needed; optionally tweak the ADR wording.

* Automatically rendered in a Shadow DOM so host-page selectors cannot style
* the button's internal DOM. No consumer wrapper or opt-in flag is required.
*/
export const YouVersionAuthButton = withShadowIsolation(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Informational only — low confidence, non-blocking (out of scope for this POC).

Isolation only applies because this component is manually wrapped in withShadowIsolation. As the pattern rolls out, a future component that forgets the wrapper would silently lose isolation, with no guardrail test catching it (echoes a concern raised on #215).

Caveat: this PR explicitly defers package-wide rollout — "Rollout to all exported components" is in ADR 0005's Deliberately deferred list — so this is a note for that future phase, not something to address here.

@cameronapak cameronapak left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

My overall assessment is this is a cool experiment, and I appreciate how Austin walked through his thinking and work clearly in his YouTube video.

This is a hard problem to solve, and I think that this scratches the surface in showing that Shadow DOM is a viable option, given some of the derred things are solved

I think no option to solve this problem will be incredibly straightforward, so which of the options are the lesser of two, or more, evils. (per se.)

There were time constraints that Austin was given, and this was/is a hard problem to solve. So my overall assessment is I think that Austin did a good work in showing a viable solution

Comment on lines +19 to +24
`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.

Copy link
Copy Markdown
Collaborator

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

Comment on lines +61 to +69
## 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Comment on lines +71 to +90
## Deliberately deferred

- Rollout to all exported components.
- Radix popover/dialog portal placement and focus management.
- 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

praise: I appreciate seeing this deferred section. The communication is helpful

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

praise: I like the simplicity of this approach

nitpick: I imagine this approach is also what causes the SSR issues, and then not rendering until the ref is available

Comment thread packages/ui/src/lib/shadow-root-host.tsx Outdated
Addresses PR review feedback: the non-adoptedStyleSheets fallback
rendered a bare <style> tag inside the shadow-root portal. Use
React 19's <style href precedence> resource form instead so React
hoists and de-duplicates the stylesheet within the shadow root
rather than risking duplicate insertion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cameronapak
cameronapak changed the base branch from main to journey-to-the-shadow-dom August 19, 2026 17:34
@cameronapak

Copy link
Copy Markdown
Collaborator

Same-repo copy is up so CI can read YVP_API_HOST / YVP_APP_KEY: #347

Fork PRs do not get those secrets. This branch is the same SHA (f735fcb). No code change.

Code Implementor, sent on behalf of Cam. I am AI.

@cameronapak

Copy link
Copy Markdown
Collaborator

Closed due to #322 (comment). Feature PR branch available at #348

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.

4 participants