Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/safe-shadows-fallback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': patch
---

Keep isolated UI content rendered with local styles when constructable stylesheet setup fails.
8 changes: 7 additions & 1 deletion docs/shadow-dom-rollout-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ supported component contract.
| Open roots are inspectable and mutable by same-page JavaScript | Accepted | Isolation is not a security boundary. |
| Opening a peer popover dismisses the current peer across component roots | Accepted | Keep the single-active-peer behavior. Supporting concurrent peer popovers would require a demonstrated product journey and separate design. |
| Nested-dialog and rapid-reopen final focus restoration | Accepted | Preserve the behavior resolved by YPE-5889/PR 414 as regression coverage rather than duplicating the implementation in rollout tickets. |
| Stylesheet construction or adoption can throw | Blocker | The foundation ticket must prove recovery to the local `<style>` path without losing component rendering. |
| Stylesheet construction or adoption can throw | Resolved by YPE-5947 | Construction, replacement, and adoption failures recover to the local `<style>` path without losing component rendering; later roots can still use the per-document constructable sheet. |
| Playwright WebKit is not actual Safari | Follow-up | Repeat SafariDriver validation before the coordinated release candidate and after changes to the host, stylesheet installation, portal controller, or focus controller. |
| Real screen-reader behavior is unverified | Follow-up | Keep real assistive-technology validation deferred and do not claim VoiceOver, NVDA, or other assistive-technology validation in release notes. |

Expand Down Expand Up @@ -135,6 +135,12 @@ exported function. Each ticket links to YPE-5356 and this policy.
- Land all included groups, complete the package and component gates, update
consumer documentation, and publish the behavior as one major release.

YPE-5947 adds no public Shadow DOM configuration and does not expand the current
automatic boundary beyond `YouVersionAuthButton`. Its independently releasable
runtime effect is a patch-level resilience fix for stylesheet installation; the
additional automatic component boundaries remain part of the coordinated major
release.

Excluded components are not hidden work in these groups. `Textarea` needs a
separately approved form contract, and standalone `VerseActionPopover` needs an
anchor contract that does not cross tree scopes before either can join a future
Expand Down
21 changes: 21 additions & 0 deletions packages/ui/src/lib/shadow-isolation.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,44 @@
import {
createContext,
createElement,
forwardRef,
useContext,
type ForwardRefExoticComponent,
type PropsWithoutRef,
type ReactNode,
type RefAttributes,
} from 'react';
import { ShadowRootHost } from './shadow-root-host';

const ShadowBoundaryReuseContext = createContext(false);

/** @internal Marks SDK-owned composition that must reuse an existing automatic boundary. */
export function ReuseShadowBoundary({ children }: { children: ReactNode }): ReactNode {
return (
<ShadowBoundaryReuseContext.Provider value>{children}</ShadowBoundaryReuseContext.Provider>
);
}

/** @internal Applies automatic isolation while preserving the component ref. */
export function withShadowIsolation<P extends object, T>(
Implementation: ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>,
displayName: string,
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>> {
const Isolated = forwardRef<T, P>((props, ref) => {
const reuseBoundary = useContext(ShadowBoundaryReuseContext);
const implementationProps: PropsWithoutRef<P> & RefAttributes<T> = {
...props,
ref,
};

if (reuseBoundary) {
return (
<ShadowBoundaryReuseContext.Provider value={false}>
{createElement(Implementation, implementationProps)}
</ShadowBoundaryReuseContext.Provider>
);
}

return (
<ShadowRootHost>{createElement(Implementation, implementationProps)}</ShadowRootHost>
);
Expand Down
224 changes: 222 additions & 2 deletions packages/ui/src/lib/shadow-root-host.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { createRef, forwardRef, StrictMode, useLayoutEffect, useState } from 'react';
import {
createRef,
forwardRef,
StrictMode,
useLayoutEffect,
useState,
type ReactNode,
} from 'react';
import { hydrateRoot, type Root } from 'react-dom/client';
import { renderToString } from 'react-dom/server';
import { act, render, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { withShadowIsolation } from './shadow-isolation';
import { ReuseShadowBoundary, withShadowIsolation } from './shadow-isolation';
import { ShadowRootHost, useShadowPortalTarget } from './shadow-root-host';

function PortalRequester(): React.ReactNode {
Expand All @@ -25,7 +32,208 @@ const IsolatedRefProbe = withShadowIsolation(
'IsolatedRefProbe',
);

interface CompositionProbeProps {
children?: ReactNode;
label: string;
}

const IsolatedCompositionProbe = withShadowIsolation(
forwardRef<HTMLDivElement, CompositionProbeProps>(({ children, label }, ref) => (
<div ref={ref} data-testid={label}>
{children}
</div>
)),
'IsolatedCompositionProbe',
);

type StyleSheetFailureStage = 'adoption' | 'construction' | 'replacement';

function installWorkingConstructableStyleSheets(ownerDocument: Document): void {
const ownerWindow = ownerDocument.defaultView!;
const shadowRootPrototype = Object.getPrototypeOf(
ownerDocument.createElement('div').attachShadow({ mode: 'open' }),
);
const adoptedStyleSheets = new WeakMap<ShadowRoot, CSSStyleSheet[]>();

Object.defineProperty(shadowRootPrototype, 'adoptedStyleSheets', {
configurable: true,
get(this: ShadowRoot): CSSStyleSheet[] {
return adoptedStyleSheets.get(this) ?? [];
},
set(this: ShadowRoot, sheets: CSSStyleSheet[]) {
adoptedStyleSheets.set(this, sheets);
},
});
Object.defineProperty(ownerWindow, 'CSSStyleSheet', {
configurable: true,
value: class TestStyleSheet {
replaceSync(): void {}
},
});
}

async function expectStyleSheetFailureRecovery(stage: StyleSheetFailureStage): Promise<void> {
const iframe = document.createElement('iframe');
const laterIframe = document.createElement('iframe');
document.body.append(iframe);
const ownerWindow = iframe.contentWindow!;
const ownerDocument = iframe.contentDocument!;
const ownerShadowRootPrototype = Object.getPrototypeOf(
ownerDocument.createElement('div').attachShadow({ mode: 'open' }),
);
const adoptedStyleSheets = new WeakMap<ShadowRoot, CSSStyleSheet[]>();
const failedContainer = ownerDocument.createElement('div');
const recoveredContainer = ownerDocument.createElement('div');
const sharedContainer = ownerDocument.createElement('div');
ownerDocument.body.append(failedContainer, recoveredContainer, sharedContainer);
let rejectFailure = true;
let unmountFailed: (() => void) | undefined;
let unmountRecovered: (() => void) | undefined;
let unmountShared: (() => void) | undefined;
let unmountLaterDocument: (() => void) | undefined;

try {
Object.defineProperty(ownerShadowRootPrototype, 'adoptedStyleSheets', {
configurable: true,
get(this: ShadowRoot): CSSStyleSheet[] {
return adoptedStyleSheets.get(this) ?? [];
},
set(this: ShadowRoot, sheets: CSSStyleSheet[]) {
if (stage === 'adoption' && rejectFailure) throw new Error('adoption failed');
adoptedStyleSheets.set(this, sheets);
},
});
Object.defineProperty(ownerWindow, 'CSSStyleSheet', {
configurable: true,
value: class TestStyleSheet {
constructor() {
if (stage === 'construction' && rejectFailure) {
throw new Error('construction failed');
}
}

replaceSync(): void {
if (stage === 'replacement' && rejectFailure) {
throw new Error('replacement failed');
}
}
},
});

expect(() => {
const view = render(
<ShadowRootHost>
<span data-testid="fallback-content">Fallback content</span>
</ShadowRootHost>,
{ container: failedContainer },
);
unmountFailed = view.unmount;
}).not.toThrow();

const failedRoot = await waitFor(() => {
const root = failedContainer.querySelector<HTMLElement>('[data-yv-shadow-host]')?.shadowRoot;
if (!root?.querySelector('[data-testid="fallback-content"]')) {
throw new Error('fallback content not rendered');
}
return root;
});
expect(failedRoot.querySelector('style[data-href="yv-sdk-shadow-styles"]')).not.toBeNull();

rejectFailure = false;
const recoveredView = render(
<ShadowRootHost>
<span data-testid="constructed-content">Constructed content</span>
</ShadowRootHost>,
{ container: recoveredContainer },
);
unmountRecovered = recoveredView.unmount;

const recoveredRoot = await waitFor(() => {
const root = recoveredContainer.querySelector<HTMLElement>('[data-yv-shadow-host]')
?.shadowRoot;
if (!root?.querySelector('[data-testid="constructed-content"]')) {
throw new Error('constructed content not rendered');
}
return root;
});
expect(recoveredRoot.adoptedStyleSheets).toHaveLength(1);
expect(recoveredRoot.querySelector('style')).toBeNull();

const sharedView = render(
<ShadowRootHost>
<span data-testid="shared-content">Shared content</span>
</ShadowRootHost>,
{ container: sharedContainer },
);
unmountShared = sharedView.unmount;
const sharedRoot = await waitFor(() => {
const root = sharedContainer.querySelector<HTMLElement>('[data-yv-shadow-host]')?.shadowRoot;
if (!root?.querySelector('[data-testid="shared-content"]')) {
throw new Error('shared content not rendered');
}
return root;
});
expect(sharedRoot.adoptedStyleSheets[0]).toBe(recoveredRoot.adoptedStyleSheets[0]);

document.body.append(laterIframe);
const laterDocument = laterIframe.contentDocument!;
installWorkingConstructableStyleSheets(laterDocument);
const laterContainer = laterDocument.createElement('div');
laterDocument.body.append(laterContainer);
const laterView = render(
<ShadowRootHost>
<span data-testid="later-document-content">Later document content</span>
</ShadowRootHost>,
{ container: laterContainer },
);
unmountLaterDocument = laterView.unmount;
const laterRoot = await waitFor(() => {
const root = laterContainer.querySelector<HTMLElement>('[data-yv-shadow-host]')?.shadowRoot;
if (!root?.querySelector('[data-testid="later-document-content"]')) {
throw new Error('later document content not rendered');
}
return root;
});
expect(laterRoot.adoptedStyleSheets).toHaveLength(1);
expect(laterRoot.adoptedStyleSheets[0]).not.toBe(recoveredRoot.adoptedStyleSheets[0]);
expect(laterRoot.querySelector('style')).toBeNull();
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} finally {
unmountFailed?.();
unmountRecovered?.();
unmountShared?.();
unmountLaterDocument?.();
iframe.remove();
laterIframe.remove();
}
}

describe('ShadowRootHost', () => {
it('reuses the owning boundary for SDK composition without flattening consumer-created nesting', async () => {
const { container } = render(
<IsolatedCompositionProbe label="owner">
<ReuseShadowBoundary>
<IsolatedCompositionProbe label="sdk-owned child">
<IsolatedCompositionProbe label="consumer child" />
</IsolatedCompositionProbe>
</ReuseShadowBoundary>
</IsolatedCompositionProbe>,
);

const outerHost = await waitFor(() => {
const host = container.querySelector<HTMLElement>('[data-yv-shadow-host]');
if (!host?.shadowRoot) throw new Error('outer shadow root not attached');
return host;
});
const outerRoot = outerHost.shadowRoot!;
const sdkOwnedChild = outerRoot.querySelector('[data-testid="sdk-owned child"]');
const consumerHost = outerRoot.querySelector<HTMLElement>('[data-yv-shadow-host]');

expect(container.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(1);
expect(sdkOwnedChild?.getRootNode()).toBe(outerRoot);
expect(outerRoot.querySelectorAll('[data-yv-shadow-host]')).toHaveLength(1);
expect(consumerHost?.shadowRoot?.querySelector('[data-testid="consumer child"]')).not.toBeNull();
});

it('reuses the empty server host before making the forwarded ref available after shadow mount', async () => {
const buttonRef = createRef<HTMLButtonElement>();
const refsDuringInitialClientRender: Array<HTMLButtonElement | null> = [];
Expand Down Expand Up @@ -148,6 +356,18 @@ describe('ShadowRootHost', () => {
expect(style?.getAttribute('data-precedence')).toBe('yv-sdk');
});

it('recovers from stylesheet construction failure without poisoning later roots or documents', async () => {
await expectStyleSheetFailureRecovery('construction');
});

it('recovers from stylesheet replacement failure without poisoning later roots or documents', async () => {
await expectStyleSheetFailureRecovery('replacement');
});

it('recovers from stylesheet adoption failure without poisoning later roots or documents', async () => {
await expectStyleSheetFailureRecovery('adoption');
});

it('creates a local portal lazily only after an overlay requests one', async () => {
const { container } = render(
<>
Expand Down
15 changes: 12 additions & 3 deletions packages/ui/src/lib/shadow-root-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,17 @@ function getOrCreateSdkStyleSheet(root: ShadowRoot): CSSStyleSheet {
return sheet;
}

function adoptSdkStyleSheet(root: ShadowRoot): boolean {
if (!supportsAdoptedStyleSheets(root)) return false;

try {
root.adoptedStyleSheets = [getOrCreateSdkStyleSheet(root)];
return true;
} catch {
return false;
}
}

function resetHost(host: HTMLDivElement): void {
// The host page can select this light-DOM element, including with !important.
// Inline author-important declarations establish the smallest stable box.
Expand Down Expand Up @@ -363,9 +374,7 @@ export function ShadowRootHost({ children, portalStrategy }: ShadowRootHostProps
shadowRootRef.current = root;
if (!existingRoot) {
resetHost(host);
if (supportsAdoptedStyleSheets(root)) {
root.adoptedStyleSheets = [getOrCreateSdkStyleSheet(root)];
} else {
if (!adoptSdkStyleSheet(root)) {
setNeedsStyleFallback(true);
}
}
Expand Down
Loading