diff --git a/.changeset/safe-phrasing-hosts.md b/.changeset/safe-phrasing-hosts.md new file mode 100644 index 00000000..5b2eb826 --- /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/docs/adr/0010-automatic-shadow-dom-style-isolation.md b/docs/adr/0010-automatic-shadow-dom-style-isolation.md index 97fa65c7..19e8e727 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 b19266f1..25ec623f 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 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 00000000..965ea89d --- /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.tsx b/packages/ui/src/components/YouVersionAuthButton.tsx index e076541d..22d27449 100644 --- a/packages/ui/src/components/YouVersionAuthButton.tsx +++ b/packages/ui/src/components/YouVersionAuthButton.tsx @@ -276,7 +276,7 @@ YouVersionAuthButtonImpl.displayName = 'YouVersionAuthButtonImpl'; */ export const YouVersionAuthButton = React.forwardRef( (props, ref) => ( - + ), diff --git a/packages/ui/src/components/bible-chapter-picker.tsx b/packages/ui/src/components/bible-chapter-picker.tsx index 188f256f..891eb3d8 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 ffec32b7..851ac597 100644 --- a/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx +++ b/packages/ui/src/components/bible-picker-shadow-isolation.test.tsx @@ -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 = ( - {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]'); + 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), diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index 0c15d30d..97345236 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 ( - + );