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 (
-
+
);