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-phrasing-hosts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@youversion/platform-react-ui': patch
---

Keep auth-button and Bible-picker Shadow DOM hosts valid in server-rendered phrasing content.
4 changes: 4 additions & 0 deletions docs/adr/0010-automatic-shadow-dom-style-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ ordinary boundary's declarative display:
<div data-yv-shadow-host="true" style="display:contents"></div>
```

Components that can be in phrasing content (`YouVersionAuthButton`, the picker
roots, `Separator`, and `ProfileAvatar`) use a `span` host with the same
attributes.

The first client render matches that markup. A layout effect applies the
protected host reset before paint. After hydration, a passive effect attaches
or reuses one open shadow root, installs the SDK stylesheet, and then portals
Expand Down
4 changes: 4 additions & 0 deletions docs/shadow-dom-isolation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,10 @@ per-story results, screenshots, raw terminal output, or other local test
artifacts. Repeat actual-Safari validation after future rollout-policy trigger
changes.

YPE-6218 repeated the targeted actual-Safari gate for the host-tag change at
commit `1d1bddb`; all 42 stories passed, with the YPE-6214 limitations
unchanged.

### Assistive-technology evidence

Record versions from the tested machines rather than naming only the product
Expand Down
93 changes: 93 additions & 0 deletions packages/ui/src/components/YouVersionAuthButton.ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* @vitest-environment jsdom
*/
import { PassThrough } from 'node:stream';
import { act, waitFor } from '@testing-library/react';
import { YouVersionProvider } from '@youversion/platform-react-hooks';
import type { ReactElement } from 'react';
import { hydrateRoot, type Root } from 'react-dom/client';
import { renderToPipeableStream } from 'react-dom/server';
import { expect, it, vi } from 'vitest';
import { requireShadowRoot } from '@/test/dom-stubs';
import { YouVersionAuthButton } from './YouVersionAuthButton';

function renderAfterAllReady(element: ReactElement): Promise<string> {
return new Promise((resolve, reject) => {
const destination = new PassThrough();
let html = '';

destination.setEncoding('utf8');
destination.on('data', (chunk: string) => {
html += chunk;
});
destination.on('end', () => resolve(html));
destination.on('error', reject);

const { pipe } = renderToPipeableStream(element, {
onAllReady() {
pipe(destination);
},
onShellError: reject,
});
});
}

it('preserves auth-button phrasing content through streaming SSR and hydration', async () => {
const element = (
<YouVersionProvider
appKey="test-app-key"
authRedirectUrl="https://example.com/callback"
includeAuth
>
<p>
Before <YouVersionAuthButton /> after
</p>
</YouVersionProvider>
);
const serverMarkup = await renderAfterAllReady(element);

expect(serverMarkup).toContain(
'<span data-yv-shadow-host="true" style="display:contents"></span>',
);

const container = document.createElement('div');
container.innerHTML = serverMarkup;
document.body.append(container);
const paragraph = container.querySelector('p');
const serverHost = container.querySelector<HTMLElement>('[data-yv-shadow-host]');
const recoverableErrors: unknown[] = [];
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
let root: Root | undefined;

try {
expect(container.querySelectorAll('p')).toHaveLength(1);
expect(paragraph).toContainElement(serverHost);
expect(paragraph).toHaveTextContent('Before after');

await act(async () => {
root = hydrateRoot(container, element, {
onRecoverableError: (error) => recoverableErrors.push(error),
});
});

const shadowRoot = await waitFor(() => requireShadowRoot(container));
expect(container.querySelector('[data-yv-shadow-host]')).toBe(serverHost);
expect(shadowRoot.querySelector('button')).not.toBeNull();
expect(recoverableErrors).toEqual([]);

// The provider can emit unrelated multiple-renderer errors when server and client
// renderers share this process, so only reject diagnostics for this regression.
const invalidNestingDiagnostics = consoleError.mock.calls.filter((call) =>
call.some((value) =>
/cannot be a (?:child|descendant) of|cannot contain a nested|validateDOMNesting/i.test(
String(value),
),
),
);
expect(invalidNestingDiagnostics).toEqual([]);
} finally {
if (root) await act(async () => root?.unmount());
consoleError.mockRestore();
container.remove();
}
});
2 changes: 1 addition & 1 deletion packages/ui/src/components/YouVersionAuthButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ YouVersionAuthButtonImpl.displayName = 'YouVersionAuthButtonImpl';
*/
export const YouVersionAuthButton = React.forwardRef<HTMLButtonElement, YouVersionAuthButtonProps>(
(props, ref) => (
<ShadowIsolationBoundary theme={props.background}>
<ShadowIsolationBoundary hostElement="span" theme={props.background}>
<YouVersionAuthButtonImpl {...props} ref={ref} />
</ShadowIsolationBoundary>
),
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/components/bible-chapter-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,11 @@ function RootImplementation({

function Root(props: RootProps) {
return (
<ShadowIsolationBoundary portalStrategy="local-top-layer" theme={props.background}>
<ShadowIsolationBoundary
hostElement="span"
portalStrategy="local-top-layer"
theme={props.background}
>
<RootImplementation {...props} />
</ShadowIsolationBoundary>
);
Expand Down
36 changes: 32 additions & 4 deletions packages/ui/src/components/bible-picker-shadow-isolation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,24 +90,52 @@ describe('Bible picker public shadow boundaries', () => {
it.each([
['BibleChapterPicker.Root', chapterPicker],
['BibleVersionPicker.Root', versionPicker],
])('%s owns one childless styled SSR host and reuses it during hydration', async (_, picker) => {
])('%s preserves phrasing content around its SSR host during hydration', async (_, picker) => {
const fixture = picker();
const element = (
<HookOverrideProvider overrides={overrides}>{fixture.element}</HookOverrideProvider>
<p>
Before <HookOverrideProvider overrides={overrides}>{fixture.element}</HookOverrideProvider>{' '}
after
</p>
);
const serverMarkup = renderToString(element);

expect(serverMarkup).toBe('<div data-yv-shadow-host="true" style="display:contents"></div>');
expect(serverMarkup).toContain(
'<span data-yv-shadow-host="true" style="display:contents"></span>',
);

const container = document.createElement('div');
container.innerHTML = serverMarkup;
document.body.append(container);
const serverHost = container.firstElementChild;
const paragraph = container.querySelector('p');
const serverHost = container.querySelector<HTMLElement>('[data-yv-shadow-host]');
if (!paragraph || !serverHost) throw new Error('parsed picker phrasing fixture is incomplete');
const recoverableErrors: unknown[] = [];
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
let root: Root | undefined;

try {
expect(container.querySelectorAll('p')).toHaveLength(1);
expect(paragraph).toContainElement(serverHost);
expect(paragraph).toHaveTextContent('Before after');
const phrasingNodes = Array.from(paragraph.childNodes).filter(
(node) => node.nodeType !== Node.COMMENT_NODE,
);
const hostIndex = phrasingNodes.indexOf(serverHost);
expect(hostIndex).toBeGreaterThan(0);
expect(
phrasingNodes
.slice(0, hostIndex)
.map((node) => node.textContent)
.join(''),
).toBe('Before ');
expect(
phrasingNodes
.slice(hostIndex + 1)
.map((node) => node.textContent)
.join(''),
).toBe(' after');

await act(async () => {
root = hydrateRoot(container, element, {
onRecoverableError: (error) => recoverableErrors.push(error),
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/components/bible-version-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -521,7 +521,11 @@ function RootImplementation({

function Root(props: RootProps) {
return (
<ShadowIsolationBoundary portalStrategy="local-top-layer" theme={props.background}>
<ShadowIsolationBoundary
hostElement="span"
portalStrategy="local-top-layer"
theme={props.background}
>
<RootImplementation {...props} />
</ShadowIsolationBoundary>
);
Expand Down
Loading