Skip to content

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

Open
abharms wants to merge 10 commits into
youversion:mainfrom
abharms:poc/shadow-dom-style-isolation
Open

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

Conversation

@abharms

@abharms abharms commented Aug 7, 2026

Copy link
Copy Markdown

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 isolation for YouVersionAuthButton while preserving its React props and forwarded ref.

  • Adds a reusable shadow host and higher-order isolation wrapper with per-document constructable stylesheet caching and a <style> fallback.
  • Moves the Auth Button implementation into an open shadow root and explicitly applies its intended font utility.
  • Adds hostile-CSS, Strict Mode, interaction, and same-origin iframe coverage.
  • Adds a Vite demonstration page, architectural decision record, and coordinated major changeset.

Confidence Score: 5/5

The prototype appears safe to merge within its explicitly documented proof-of-concept scope.

The changed component mounts through an open shadow root, installs styles with a feature-detected fallback, preserves its forwarded ref and interaction path, and includes focused coverage for the principal behavior introduced; the remaining compatibility effects are explicitly documented as known prototype limitations.

Important Files Changed

Filename Overview
packages/ui/src/lib/shadow-root-host.tsx Adds effect-driven shadow-root creation, host resets, per-document stylesheet caching, fallback styling, and portal rendering; no unacknowledged concrete defect was established.
packages/ui/src/lib/shadow-isolation.tsx Adds a typed ref-preserving wrapper that renders component implementations through ShadowRootHost.
packages/ui/src/components/YouVersionAuthButton.tsx Wraps the public Auth Button in automatic isolation and pins its intended sans-serif utility while preserving implementation props and ref forwarding.
packages/ui/src/styles/global.css Adds shadow-host pseudo-element suppression to prevent host CSS from injecting generated content.
packages/ui/src/components/YouVersionAuthButton.shadow-isolation.stories.tsx Adds focused Chromium coverage for hostile global CSS and same-origin iframe stylesheet adoption.
packages/ui/src/lib/shadow-root-host.test.tsx Adds unit coverage for Strict Mode mounting and important host reset declarations.
docs/adr/0005-prototype-shadow-dom-style-isolation.md Documents the prototype architecture, compatibility impact, verified scope, and deliberately deferred limitations.
examples/vite-react/src/pages/HostileCssPage.tsx Adds an interactive demonstration comparing isolated SDK rendering against deliberately affected light-DOM controls.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Consumer[Consumer renders YouVersionAuthButton] --> Wrapper[withShadowIsolation]
  Wrapper --> Host[Light-DOM shadow host]
  Host --> Root[Open ShadowRoot]
  Root --> Styles[Per-document constructed stylesheet or style fallback]
  Root --> Portal[React portal]
  Portal --> Button[YouVersionAuthButtonImpl]
  HostCSS[Host application CSS] -. blocked from selecting internals .-> Root
Loading

Reviews (1): Last reviewed commit: "docs(examples): keep hostile demo labels..." | Re-trigger Greptile

@changeset-bot

changeset-bot Bot commented Aug 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7a311b3

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.

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.

3 participants