From 1d1bddb8e6dfd10473fd20455c5a3402eba15ecb Mon Sep 17 00:00:00 2001 From: Austin Date: Fri, 30 Oct 2026 17:12:47 -0500 Subject: [PATCH 1/4] fix(ui): keep inline shadow hosts phrasing-safe --- .changeset/safe-phrasing-hosts.md | 5 ++ .../components/YouVersionAuthButton.test.tsx | 83 ++++++++++++++++++- .../src/components/YouVersionAuthButton.tsx | 2 +- .../src/components/bible-chapter-picker.tsx | 6 +- .../bible-picker-shadow-isolation.test.tsx | 16 +++- .../src/components/bible-version-picker.tsx | 6 +- 6 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 .changeset/safe-phrasing-hosts.md diff --git a/.changeset/safe-phrasing-hosts.md b/.changeset/safe-phrasing-hosts.md new file mode 100644 index 000000000..5b2eb826a --- /dev/null +++ b/.changeset/safe-phrasing-hosts.md @@ -0,0 +1,5 @@ +--- +'@youversion/platform-react-ui': patch +--- + +Keep auth-button and Bible-picker Shadow DOM hosts valid in server-rendered phrasing content. diff --git a/packages/ui/src/components/YouVersionAuthButton.test.tsx b/packages/ui/src/components/YouVersionAuthButton.test.tsx index 11d9f5eec..0154127df 100644 --- a/packages/ui/src/components/YouVersionAuthButton.test.tsx +++ b/packages/ui/src/components/YouVersionAuthButton.test.tsx @@ -1,14 +1,95 @@ /** * @vitest-environment jsdom */ -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { PassThrough } from 'node:stream'; +import { act, fireEvent, render, waitFor } from '@testing-library/react'; import { YouVersionAPIUsers, YouVersionPlatformConfiguration } from '@youversion/platform-core'; 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 { describe, expect, it, vi } from 'vitest'; import { requireHtmlButton, requireShadowRoot } from '@/test/dom-stubs'; import { YouVersionAuthButton } from './YouVersionAuthButton'; +function renderAfterAllReady(element: ReactElement): Promise { + 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, + }); + }); +} + describe('YouVersionAuthButton', () => { + it('preserves phrasing-content markup through streaming SSR and hydration', async () => { + const element = ( + +

+ Before after +

+
+ ); + const serverMarkup = await renderAfterAllReady(element); + + expect(serverMarkup).toContain('('[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([]); + + const invalidNestingDiagnostics = consoleError.mock.calls.filter((call) => + call.some((value) => + /cannot be a 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(); + } + }); + it('uses an explicit background for the automatic shadow theme scope', async () => { const { container } = render( ( (props, ref) => ( - + ), diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index 188f256fa..891eb3d87 100644 --- a/packages/ui/src/components/bible-chapter-picker.tsx +++ b/packages/ui/src/components/bible-chapter-picker.tsx @@ -272,7 +272,11 @@ function RootImplementation({ function Root(props: RootProps) { return ( - + ); diff --git a/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx b/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx index ffec32b7f..0457b8833 100644 --- a/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx +++ b/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx @@ -93,21 +93,31 @@ describe('Bible picker public shadow boundaries', () => { ])('%s owns one childless styled SSR host and reuses it during hydration', async (_, picker) => { const fixture = picker(); const element = ( - {fixture.element} +

+ Before {fixture.element}{' '} + after +

); const serverMarkup = renderToString(element); - expect(serverMarkup).toBe('
'); + expect(serverMarkup).toContain( + '', + ); 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('[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), diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index 0c15d30d6..973452364 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -521,7 +521,11 @@ function RootImplementation({ function Root(props: RootProps) { return ( - + ); From 5e836ed2378535717e00acfde1d0b635c7509785 Mon Sep 17 00:00:00 2001 From: Austin Date: Fri, 30 Oct 2026 21:23:08 -0500 Subject: [PATCH 2/4] test(ui): strengthen phrasing host coverage --- .../components/YouVersionAuthButton.test.tsx | 8 ++++++-- .../bible-picker-shadow-isolation.test.tsx | 20 ++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/YouVersionAuthButton.test.tsx b/packages/ui/src/components/YouVersionAuthButton.test.tsx index 0154127df..bb3c5138a 100644 --- a/packages/ui/src/components/YouVersionAuthButton.test.tsx +++ b/packages/ui/src/components/YouVersionAuthButton.test.tsx @@ -48,7 +48,9 @@ describe('YouVersionAuthButton', () => { ); const serverMarkup = await renderAfterAllReady(element); - expect(serverMarkup).toContain('', + ); const container = document.createElement('div'); container.innerHTML = serverMarkup; @@ -75,9 +77,11 @@ describe('YouVersionAuthButton', () => { 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 descendant of

|cannot contain a nested|validateDOMNesting/i.test( + /cannot be a (?:child|descendant) of|cannot contain a nested|validateDOMNesting/i.test( String(value), ), ), diff --git a/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx b/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx index 0457b8833..851ac5974 100644 --- a/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx +++ b/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx @@ -90,7 +90,7 @@ 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 = (

@@ -109,6 +109,7 @@ describe('Bible picker public shadow boundaries', () => { document.body.append(container); const paragraph = container.querySelector('p'); const serverHost = container.querySelector('[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; @@ -117,6 +118,23 @@ describe('Bible picker public shadow boundaries', () => { 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, { From 49abb72931f031072a11d9990193c43317e2427d Mon Sep 17 00:00:00 2001 From: Austin Date: Mon, 2 Nov 2026 09:11:42 -0600 Subject: [PATCH 3/4] test(ui): isolate auth streaming SSR coverage --- .../YouVersionAuthButton.ssr.test.tsx | 93 +++++++++++++++++++ .../components/YouVersionAuthButton.test.tsx | 87 +---------------- 2 files changed, 94 insertions(+), 86 deletions(-) create mode 100644 packages/ui/src/components/YouVersionAuthButton.ssr.test.tsx diff --git a/packages/ui/src/components/YouVersionAuthButton.ssr.test.tsx b/packages/ui/src/components/YouVersionAuthButton.ssr.test.tsx new file mode 100644 index 000000000..965ea89dc --- /dev/null +++ b/packages/ui/src/components/YouVersionAuthButton.ssr.test.tsx @@ -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 { + 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 = ( + +

+ Before after +

+ + ); + const serverMarkup = await renderAfterAllReady(element); + + expect(serverMarkup).toContain( + '', + ); + + const container = document.createElement('div'); + container.innerHTML = serverMarkup; + document.body.append(container); + const paragraph = container.querySelector('p'); + const serverHost = container.querySelector('[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(); + } +}); diff --git a/packages/ui/src/components/YouVersionAuthButton.test.tsx b/packages/ui/src/components/YouVersionAuthButton.test.tsx index bb3c5138a..11d9f5eec 100644 --- a/packages/ui/src/components/YouVersionAuthButton.test.tsx +++ b/packages/ui/src/components/YouVersionAuthButton.test.tsx @@ -1,99 +1,14 @@ /** * @vitest-environment jsdom */ -import { PassThrough } from 'node:stream'; -import { act, fireEvent, render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { YouVersionAPIUsers, YouVersionPlatformConfiguration } from '@youversion/platform-core'; 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 { describe, expect, it, vi } from 'vitest'; import { requireHtmlButton, requireShadowRoot } from '@/test/dom-stubs'; import { YouVersionAuthButton } from './YouVersionAuthButton'; -function renderAfterAllReady(element: ReactElement): Promise { - 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, - }); - }); -} - describe('YouVersionAuthButton', () => { - it('preserves phrasing-content markup through streaming SSR and hydration', async () => { - const element = ( - -

- Before after -

-
- ); - const serverMarkup = await renderAfterAllReady(element); - - expect(serverMarkup).toContain( - '', - ); - - const container = document.createElement('div'); - container.innerHTML = serverMarkup; - document.body.append(container); - const paragraph = container.querySelector('p'); - const serverHost = container.querySelector('[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(); - } - }); - it('uses an explicit background for the automatic shadow theme scope', async () => { const { container } = render( Date: Mon, 2 Nov 2026 15:06:57 -0600 Subject: [PATCH 4/4] docs(ui): record phrasing host validation --- docs/adr/0010-automatic-shadow-dom-style-isolation.md | 4 ++++ docs/shadow-dom-isolation-plan.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/docs/adr/0010-automatic-shadow-dom-style-isolation.md b/docs/adr/0010-automatic-shadow-dom-style-isolation.md index 97fa65c70..19e8e7277 100644 --- a/docs/adr/0010-automatic-shadow-dom-style-isolation.md +++ b/docs/adr/0010-automatic-shadow-dom-style-isolation.md @@ -108,6 +108,10 @@ ordinary boundary's declarative display:
``` +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 diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index b19266f15..25ec623f2 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -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