Skip to content
Merged
2 changes: 2 additions & 0 deletions .changeset/tidy-shadow-events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
9 changes: 6 additions & 3 deletions docs/shadow-dom-consumer-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ exercise. They do not establish compatibility for every SDK component.
| An internal control resolves light-DOM `aria-labelledby` or `aria-describedby` ID references | Unsupported | The attributes remain present, but reflected element arrays are empty across the boundary in Chromium, Firefox, and Playwright WebKit. Keep referenced nodes in the same tree scope. This DOM evidence is not a substitute for assistive-technology testing. |
| A native composed event crosses one shadow boundary | Supported with native retargeting | `EventsRefsAndDomQueriesExposeDifferentConsumerViews` clicks an internal label element and verifies that a light-DOM listener receives the shadow host as `event.target`; `composedPath()` begins with the label and includes the internal button and host. Consumers must not assume an external native listener's target is the internal control. |
| A React handler passed to `YouVersionAuthButton` receives its button event | Supported for this public component | The same story verifies that the consumer `onClick` handler receives the internal originating label as `target` and the internal button as `currentTarget`. Consumers may rely on the button current target, not on every event originating at the button itself. This is component-specific evidence, not a package-wide promise for every event prop. |
| A consumer React handler wraps an automatically isolated component in the light DOM | Known limitation; fix required before coordinated release | Real-browser validation in YPE-5950 found that one composed click can invoke the ancestor handler twice: once through the shadow-root portal path with the internal target and once through the application-root path with the event retargeted to the shadow host. Keyboard and focus dispatch need the same shared-host audit. Do not use an ancestor React handler as the only state-toggle seam until YPE-6040 deduplicates dispatch. Native listeners retain the single retargeted-event contract above. |
| A consumer React handler wraps an automatically isolated component in the light DOM | Supported only for validated bubbling events | YPE-6040 verifies that one native click or keydown, or one native `focusin` corresponding to React `onFocus`, invokes the light-DOM ancestor React handler once. That React handler receives the internal shadow node as `event.target`, so `event.currentTarget.contains(event.target)` is false; an external native listener still receives the shadow host as its retargeted target. Separate native events remain separate, and component callbacks retain their target semantics. Duplicate React ancestor delivery remains for `onBlur` when focus leaves the root, for capture-phase handlers such as `onClickCapture`, and for other composed bubbling events including `onKeyUp`, `onPointerDown`, `onMouseDown`, `onDoubleClick`, and `onContextMenu`. |
| A forwarded `YouVersionAuthButton` ref exposes the internal button | Supported after mount | The ref resolves to the exact internal `HTMLButtonElement`. It remains `null` through the consumer's first layout effect because the shadow root attaches in a passive effect; consumers must handle callback-ref updates or read object refs after a later commit. |
| An ordinary document or Storybook-canvas selector finds SDK internals | Unsupported | DOM selector APIs do not cross a shadow boundary. `document.querySelector` and Testing Library queries rooted at the document need explicit open-root traversal. Automation behavior is tool-specific: [Playwright locators pierce open roots by default](https://playwright.dev/docs/locators#locate-in-shadow-dom), except for XPath locators, while closed roots remain inaccessible. |
| A consumer traverses an open root and queries after attachment | Supported with timing and access constraints | Wait for the host's open `shadowRoot`, then query within it. The contract depends on the prototype's open-root policy and does not make internals a stable semantic API; prefer public refs, roles, and component callbacks where available. |
Expand All @@ -64,10 +64,13 @@ exercise. They do not establish compatibility for every SDK component.
Automatic isolation changes rendered DOM even when React props stay the same.
Consumer selectors and global CSS stop reaching internals; native event targets
change at every boundary; external form and ID-reference relationships stop
resolving; forwarded refs become available later; and, pending YPE-6040,
light-DOM ancestor React handlers can receive duplicate dispatch. These are compatibility
resolving; and forwarded refs become available later. These are compatibility
and potentially breaking changes, not internal implementation details.

Light-DOM ancestor React handlers can still receive duplicate dispatch for
events other than the validated bubbling click, keydown, and focus paths, and
for capture-phase handlers.

An open root permits inspection and mutation by same-page JavaScript, so it is
an automation and styling boundary rather than a security boundary. Selectors
that depend on internal markup remain fragile even when they traverse the root.
Expand Down
18 changes: 13 additions & 5 deletions docs/shadow-dom-isolation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ functions; assistive-technology checks remain open.
| Dialog dismissal and restoration | Coverage exercises Escape, backdrop click, full-viewport hit testing, overlay-only focus, and restoration after both modal nodes unmount. | Validated in Chromium, Firefox, Playwright WebKit, and isolated local Safari 26.6.2 runs | Verify real screen-reader behavior. |
| Consumer form participation | Browser coverage verifies that a light-DOM form does not own or serialize a native control inside an SDK shadow root. | Unsupported across tree scopes | Use an explicit component contract if a rollout target requires outer-form participation. |
| Consumer labels and ARIA ID references | Browser coverage verifies that external native labels, `aria-labelledby`, and `aria-describedby` relationships do not resolve to controls inside the root. | Unsupported across tree scopes | Keep relationships in one tree scope; verify real assistive technology separately. |
| Consumer events, refs, and automation | Coverage verifies native retargeting, the auth button's React handler and forwarded ref, open-root queries, and effect-driven attachment timing. | Supported with documented constraints | Repeat for each public component selected for rollout. |
| Nested shadow roots | Coverage verifies basic rendering, recursive queries, and event retargeting at each boundary. | Supported for the validated basics | Single-active-peer dismissal is accepted; verify assistive-technology behavior and repeat actual-Safari checks for significant platform changes. |
| Consumer events, refs, and automation | Coverage verifies native retargeting, the auth button's React handler and forwarded ref, open-root queries, effect-driven attachment timing, and one light-DOM ancestor dispatch for each validated bubbling click and keydown plus React `onFocus`/native `focusin` delivery. | Supported with documented constraints | Repeat for each public component selected for rollout. Capture-phase and other event types are not part of this evidence. |
| Nested shadow roots | Coverage verifies basic rendering, recursive queries, event retargeting at each boundary, and one React ancestor dispatch per scope for a nested click. | Supported for the validated basics | Single-active-peer dismissal is accepted; verify assistive-technology behavior and repeat actual-Safari checks for significant platform changes. |
| Realistic same-page usage | YPE-5437 mounts, removes, and re-adds a 12-component mix in Normal and Strict Mode. Chromium, Firefox, Playwright WebKit, and local Safari 26.6.2 coverage verifies exact host counts, rendered scripture content, and one shared stylesheet object across roots and remounts. A production-build comparison found a small warm-run mount-cost difference on one machine. | No shared-host blocker found | Repeat user-visible performance and compatibility checks for each component selected for rollout. |

## YPE-5948 leaf evidence
Expand Down Expand Up @@ -177,9 +177,17 @@ rollout independently.

Real Chromium validation also found that a consumer React handler on a
light-DOM ancestor can receive one composed event through both the shadow-root
portal path and the application-root retargeted path. The shared-host correction
and click, keyboard, and focus regression coverage are assigned to YPE-6040 and
must land before YPE-5952 releases the coordinated major version.
portal path and the application-root retargeted path.

YPE-6040 now keeps one React logical traversal for each native `click`, `keydown`,
and `focusin` event without stopping native propagation. The representative auth
button story verifies component callback counts and target semantics, exact
light-DOM ancestor counts, separate repeated clicks, native retargeting, and
the original composed path. The nested-root story retains its existing native
retargeting evidence and adds one React ancestor dispatch per scope. The main
workflow passes Chromium, Firefox, and Playwright WebKit. Because the correction
changes the shared host, YPE-5952 retains the required actual-Safari
release-candidate validation; this evidence makes no assistive-technology claim.

## YPE-5951 Bible reader evidence

Expand Down
2 changes: 1 addition & 1 deletion docs/shadow-dom-rollout-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ supported component contract.
| Ref is unavailable until the passive-effect shadow mount | Accepted | Document the timing. Component tickets verify forwarded refs where the public component exposes one. |
| Native outer-form participation and cross-tree label or ARIA ID references do not work | Accepted | Exclude `Textarea`. Included components keep their accessible relationships inside their root or expose explicit props/callbacks. |
| Document-rooted selectors and native event targets change | Accepted | Treat this as a breaking change. Document open-root traversal, role-based automation, public callbacks, and composed-event retargeting. |
| A consumer React handler on a light-DOM ancestor can receive one composed event twice | Follow-up YPE-6040 | The shared portal dispatches once through the shadow-root React listener and again through the application-root listener after native retargeting. Document the limitation for integration-branch consumers and deduplicate shared-host dispatch before YPE-5952 releases the coordinated major version. |
| A consumer React handler on a light-DOM ancestor can receive one composed event twice | Accepted for YPE-5952 | The shared host keeps one React logical traversal for each validated bubbling click and keydown plus React `onFocus`/native `focusin` delivery while preserving native propagation and retargeting. Distinct native events remain distinct, including repeated clicks. Duplicate delivery remains for `onBlur` when focus leaves the root, for capture-phase handlers such as `onClickCapture`, and for other composed bubbling events including `onKeyUp`, `onPointerDown`, `onMouseDown`, `onDoubleClick`, and `onContextMenu`. Document the supported paths and remaining limitations in the consumer compatibility contract. |
| Consumer global CSS and document-level token overrides do not style internals | Accepted | Supported props and component-owned tokens replace accidental global customization; consumer-supplied children require focused review. |
| `rem` still follows the owning document root size | Accepted | Review this sizing input per component. |
| Font loading and public `@font-face` names remain document-owned | Accepted | `YouVersionProvider` remains responsible for document fonts, including inside same-origin iframe documents. |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,69 @@
import type { Meta, StoryObj } from '@storybook/react-vite';

import { YouVersionProvider as HooksYouVersionProvider } from '@youversion/platform-react-hooks';
import { http, HttpResponse } from 'msw';
import { useEffect, useState } from 'react';
import type { BibleVersion, Language, Organization } from '@youversion/platform-core';
import { YouVersionContext, type HookOverrides } from '@youversion/platform-react-hooks';
import { useContext, useEffect, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { expect, userEvent, within } from 'storybook/test';
import { ReuseShadowBoundary } from '../lib/shadow-isolation';
import { ShadowRootHost } from '../lib/shadow-root-host';
import { globalHandlers } from '../test/mocks/handlers';
import mockBibles from '../test/mock-data/bibles.json';
import mockLanguages from '../test/mock-data/languages.json';
import mockOrganizations from '../test/mock-data/organizations.json';
import { waitFor, waitForElement, waitForShadowRoot } from '../test/storybook-dom';
import { BibleVersionPicker } from './bible-version-picker';

const VERSIONS = mockBibles.collections.default.data satisfies BibleVersion[];
const LANGUAGES = mockLanguages.data.map(({ id, display_names }) => ({
id,
language: display_names.en ?? id,
display_names: { en: display_names.en ?? id },
})) satisfies Language[];
const ORGANIZATIONS = Object.values(mockOrganizations) satisfies Organization[];

const HOOK_OVERRIDES = {
useVersion: (versionId) => ({
version: VERSIONS.find((version) => version.id === versionId) ?? null,
loading: false,
error: null,
refetch: () => undefined,
}),
useVersions: () => ({
versions: { data: VERSIONS, next_page_token: null },
loading: false,
error: null,
refetch: () => undefined,
}),
useLanguages: () => ({
languages: { data: LANGUAGES, next_page_token: null },
loading: false,
error: null,
refetch: () => undefined,
}),
useLanguage: (languageId) => ({
language: LANGUAGES.find((language) => language.id === languageId) ?? null,
loading: false,
error: null,
refetch: () => undefined,
}),
useOrganizations: (organizationIds) => ({
organizations: new Map(
ORGANIZATIONS.filter((organization) => organizationIds.includes(organization.id)).map(
(organization) => [organization.id, organization],
),
),
}),
} satisfies HookOverrides;

function FixtureProviders({ children }: { children: ReactNode }): ReactNode {
const parentContext = useContext(YouVersionContext);
const value = parentContext
? { ...parentContext, hookOverrides: HOOK_OVERRIDES }
: { appKey: 'test', hookOverrides: HOOK_OVERRIDES };

return <YouVersionContext.Provider value={value}>{children}</YouVersionContext.Provider>;
}

function AutomaticBibleVersionPicker({
side = 'top',
}: {
Expand Down Expand Up @@ -97,14 +150,7 @@ function SameOriginIframeHarness(): React.ReactNode {
style={{ inlineSize: 800, blockSize: 600 }}
onLoad={(event) => setIframeDocument(event.currentTarget.contentDocument)}
/>
{container
? createPortal(
<HooksYouVersionProvider appKey="123" apiHost="https://api.youversion.com">
<AutomaticBibleVersionPicker />
</HooksYouVersionProvider>,
container,
)
: null}
{container ? createPortal(<AutomaticBibleVersionPicker />, container) : null}
</>
);
}
Expand All @@ -115,15 +161,8 @@ const meta = {
tags: ['integration', 'shadow-dom'],
parameters: {
layout: 'centered',
msw: {
handlers: [
...globalHandlers,
http.get('*/v1/fonts/1/stylesheet', () =>
HttpResponse.text('', { headers: { 'Content-Type': 'text/css' } }),
),
],
},
},
decorators: [(Story) => <FixtureProviders>{Story()}</FixtureProviders>],
} satisfies Meta<typeof AutomaticBibleVersionPicker>;

export default meta;
Expand All @@ -136,6 +175,7 @@ async function getTrigger(root: ShadowRoot): Promise<HTMLElement> {
async function openPicker(container: ParentNode) {
const root = await waitForShadowRoot(container);
const trigger = await getTrigger(root);
await waitFor(async () => await expect(trigger).toHaveTextContent('NIV'));
await expect(root.querySelector('[data-yv-shadow-local-overlay]')).toBeNull();
await userEvent.click(trigger);
const topLayer = await waitForElement<HTMLElement>(
Expand Down
Loading
Loading