diff --git a/.changeset/tidy-shadow-events.md b/.changeset/tidy-shadow-events.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/tidy-shadow-events.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/docs/shadow-dom-consumer-compatibility.md b/docs/shadow-dom-consumer-compatibility.md index d294412f..20133fb6 100644 --- a/docs/shadow-dom-consumer-compatibility.md +++ b/docs/shadow-dom-consumer-compatibility.md @@ -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. | @@ -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. diff --git a/docs/shadow-dom-isolation-plan.md b/docs/shadow-dom-isolation-plan.md index d6024d54..005abd38 100644 --- a/docs/shadow-dom-isolation-plan.md +++ b/docs/shadow-dom-isolation-plan.md @@ -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 @@ -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 diff --git a/docs/shadow-dom-rollout-policy.md b/docs/shadow-dom-rollout-policy.md index e9f0ba3f..247b4d28 100644 --- a/docs/shadow-dom-rollout-policy.md +++ b/docs/shadow-dom-rollout-policy.md @@ -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. | diff --git a/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx index 755ea9d8..d63b557b 100644 --- a/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.shadow-isolation.stories.tsx @@ -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 {children}; +} + function AutomaticBibleVersionPicker({ side = 'top', }: { @@ -97,14 +150,7 @@ function SameOriginIframeHarness(): React.ReactNode { style={{ inlineSize: 800, blockSize: 600 }} onLoad={(event) => setIframeDocument(event.currentTarget.contentDocument)} /> - {container - ? createPortal( - - - , - container, - ) - : null} + {container ? createPortal(, container) : null} ); } @@ -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) => {Story()}], } satisfies Meta; export default meta; @@ -136,6 +175,7 @@ async function getTrigger(root: ShadowRoot): Promise { 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( diff --git a/packages/ui/src/components/consumer-compatibility.shadow-isolation.stories.tsx b/packages/ui/src/components/consumer-compatibility.shadow-isolation.stories.tsx index db623487..04a230ec 100644 --- a/packages/ui/src/components/consumer-compatibility.shadow-isolation.stories.tsx +++ b/packages/ui/src/components/consumer-compatibility.shadow-isolation.stories.tsx @@ -49,8 +49,25 @@ async function requireShadowHost(container: ParentNode): Promise { }); } -interface NodeEvidenceOutput extends HTMLOutputElement { - observedNode?: EventTarget | null; +interface ReactEventObservation { + currentTarget: EventTarget | null; + nativeEvent: Event; + target: EventTarget | null; +} + +interface AutomaticButtonEvidenceOutput extends HTMLOutputElement { + ancestorClicks?: ReactEventObservation[]; + ancestorFocuses?: ReactEventObservation[]; + ancestorKeyDowns?: ReactEventObservation[]; + componentClicks?: ReactEventObservation[]; + componentFocuses?: ReactEventObservation[]; + componentKeyDowns?: ReactEventObservation[]; + forwardedRefNode?: EventTarget | null; +} + +interface NestedRootsEvidenceOutput extends HTMLOutputElement { + lightDomAncestorClickCount?: number; + outerShadowAncestorClickCount?: number; } interface CompatibilityScenarioProps { @@ -219,9 +236,7 @@ export const FormsAndExternalRelationshipsStopAtTheTreeScope: Story = { function AutomaticButtonHarness(): React.ReactNode { const buttonRef = useRef(null); const firstLayoutRefState = useRef<'pending' | 'null' | 'resolved'>('pending'); - const forwardedRefEvidence = useRef(null); - const consumerTargetEvidence = useRef(null); - const consumerCurrentTargetEvidence = useRef(null); + const evidenceRef = useRef(null); const [firstLayoutResult, setFirstLayoutResult] = useState<'pending' | 'null' | 'resolved'>( 'pending', ); @@ -236,47 +251,95 @@ function AutomaticButtonHarness(): React.ReactNode { const receiveButtonRef = useCallback((node: HTMLButtonElement | null): void => { buttonRef.current = node; - if (forwardedRefEvidence.current) forwardedRefEvidence.current.observedNode = node; + if (evidenceRef.current) evidenceRef.current.forwardedRefNode = node; }, []); return ( -
- +
{ - if (consumerTargetEvidence.current) { - consumerTargetEvidence.current.observedNode = event.target; + if (evidenceRef.current) { + (evidenceRef.current.ancestorClicks ??= []).push({ + currentTarget: event.currentTarget, + nativeEvent: event.nativeEvent, + target: event.target, + }); } - if (consumerCurrentTargetEvidence.current) { - consumerCurrentTargetEvidence.current.observedNode = event.currentTarget; + }} + onFocus={(event) => { + if (evidenceRef.current) { + (evidenceRef.current.ancestorFocuses ??= []).push({ + currentTarget: event.currentTarget, + nativeEvent: event.nativeEvent, + target: event.target, + }); } }} - /> - -
+ onKeyDown={(event) => { + if (evidenceRef.current) { + (evidenceRef.current.ancestorKeyDowns ??= []).push({ + currentTarget: event.currentTarget, + nativeEvent: event.nativeEvent, + target: event.target, + }); + } + }} + > + { + if (evidenceRef.current) { + (evidenceRef.current.componentClicks ??= []).push({ + currentTarget: event.currentTarget, + nativeEvent: event.nativeEvent, + target: event.target, + }); + } + }} + onFocus={(event) => { + if (evidenceRef.current) { + (evidenceRef.current.componentFocuses ??= []).push({ + currentTarget: event.currentTarget, + nativeEvent: event.nativeEvent, + target: event.target, + }); + } + }} + onKeyDown={(event) => { + if (evidenceRef.current) { + (evidenceRef.current.componentKeyDowns ??= []).push({ + currentTarget: event.currentTarget, + nativeEvent: event.nativeEvent, + target: event.target, + }); + } + }} + /> + +
+ +
); } export const EventsRefsAndDomQueriesExposeDifferentConsumerViews: Story = { - name: 'Events, refs, and DOM queries expose different views', + name: 'Events dispatch once while preserving consumer views', + tags: ['cross-browser'], render: () => , play: async ({ canvasElement }) => { const observer = await waitFor(async () => @@ -301,54 +364,133 @@ export const EventsRefsAndDomQueriesExposeDifferentConsumerViews: Story = { '[data-testid="first-layout-ref-state"]', 'first layout ref state not rendered', ); - const forwardedRefEvidence = requireElement( - canvasElement, - '[data-testid="forwarded-ref-evidence"]', - 'forwarded ref evidence not rendered', - ); - const consumerTargetEvidence = requireElement( + const evidence = requireElement( canvasElement, - '[data-testid="consumer-target-evidence"]', - 'consumer target evidence not rendered', + '[data-testid="automatic-button-evidence"]', + 'automatic button evidence not rendered', ); - const consumerCurrentTargetEvidence = requireElement( + const outsideFocusTarget = requireElement( canvasElement, - '[data-testid="consumer-current-target-evidence"]', - 'consumer current target evidence not rendered', + '[data-testid="outside-focus-target"]', + 'outside focus target not rendered', ); - await expect(firstLayoutEvidence).toHaveTextContent('null'); await expect(firstLayoutEvidence).toHaveAttribute('hidden'); - await waitFor(async () => await expect(forwardedRefEvidence.observedNode).toBe(button)); + await waitFor(async () => await expect(evidence.forwardedRefNode).toBe(button)); const clickTarget = requireElement( button, 'div', 'auth button label click target not rendered', ); - let outsideTarget: EventTarget | null = null; - let outsidePath: EventTarget[] = []; - observer.addEventListener( - 'click', - (event) => { - outsideTarget = event.target; - outsidePath = event.composedPath(); - }, - { once: true }, - ); + const outsideClicks: Array<{ + event: Event; + path: EventTarget[]; + target: EventTarget | null; + }> = []; + observer.addEventListener('click', (event) => { + outsideClicks.push({ event, path: event.composedPath(), target: event.target }); + }); + const outsideKeyDowns: Array<{ + event: Event; + path: EventTarget[]; + target: EventTarget | null; + }> = []; + observer.addEventListener('keydown', (event) => { + outsideKeyDowns.push({ event, path: event.composedPath(), target: event.target }); + }); + const outsideFocusEvents: Array<{ + event: Event; + path: EventTarget[]; + target: EventTarget | null; + }> = []; + observer.addEventListener('focusin', (event) => { + outsideFocusEvents.push({ event, path: event.composedPath(), target: event.target }); + }); + + outsideFocusTarget.focus(); + button.focus(); + + await expect(root.activeElement).toBe(button); + await expect(evidence.ancestorFocuses).toEqual([ + { currentTarget: observer, nativeEvent: outsideFocusEvents[0]?.event, target: button }, + ]); + await expect(evidence.componentFocuses).toEqual([ + { currentTarget: button, nativeEvent: outsideFocusEvents[0]?.event, target: button }, + ]); + await expect(outsideFocusEvents).toHaveLength(1); + await expect(outsideFocusEvents[0]?.target).toBe(host); + await expect(outsideFocusEvents[0]?.path[0]).toBe(button); + await expect(outsideFocusEvents[0]?.path).toContain(host); + + const keyDownEvent = new button.ownerDocument.defaultView!.KeyboardEvent('keydown', { + bubbles: true, + composed: true, + key: 'Enter', + }); + button.dispatchEvent(keyDownEvent); + button.dispatchEvent(keyDownEvent); + + await expect(outsideKeyDowns).toHaveLength(2); + await expect(new Set(outsideKeyDowns.map(({ event }) => event))).toHaveLength(1); + await expect(evidence.ancestorKeyDowns).toEqual([ + { currentTarget: observer, nativeEvent: keyDownEvent, target: button }, + { currentTarget: observer, nativeEvent: keyDownEvent, target: button }, + ]); + await expect(evidence.componentKeyDowns).toEqual([ + { currentTarget: button, nativeEvent: keyDownEvent, target: button }, + { currentTarget: button, nativeEvent: keyDownEvent, target: button }, + ]); + await expect(outsideKeyDowns[1]?.target).toBe(host); + await expect(outsideKeyDowns[1]?.path[0]).toBe(button); + await expect(outsideKeyDowns[1]?.path).toContain(host); + + const hostKeyDownEvent = new button.ownerDocument.defaultView!.KeyboardEvent('keydown', { + bubbles: true, + composed: true, + key: 'Escape', + }); + host.dispatchEvent(hostKeyDownEvent); + host.dispatchEvent(hostKeyDownEvent); + + await expect(evidence.ancestorKeyDowns?.slice(2)).toEqual([ + { currentTarget: observer, nativeEvent: hostKeyDownEvent, target: host }, + { currentTarget: observer, nativeEvent: hostKeyDownEvent, target: host }, + ]); + await expect(evidence.componentKeyDowns).toHaveLength(2); + await expect(outsideKeyDowns).toHaveLength(4); + await expect(outsideKeyDowns[3]?.target).toBe(host); + await expect(outsideKeyDowns[3]?.path[0]).toBe(host); + await userEvent.click(clickTarget); await userEvent.click(clickTarget); - await expect(outsideTarget).toBe(host); - await expect(outsidePath[0]).toBe(clickTarget); - await expect(outsidePath).toContain(button); - await expect(outsidePath).toContain(host); - await expect(consumerTargetEvidence.observedNode).toBe(clickTarget); - await expect(consumerCurrentTargetEvidence.observedNode).toBe(button); + await expect(outsideClicks).toHaveLength(2); + await expect(new Set(outsideClicks.map(({ event }) => event))).toHaveLength(2); + await expect(evidence.ancestorClicks).toEqual( + outsideClicks.map(({ event }) => ({ + currentTarget: observer, + nativeEvent: event, + target: clickTarget, + })), + ); + await expect(evidence.componentClicks).toEqual( + outsideClicks.map(({ event }) => ({ + currentTarget: button, + nativeEvent: event, + target: clickTarget, + })), + ); + await expect(outsideClicks[1]?.target).toBe(host); + await expect(outsideClicks[1]?.path[0]).toBe(clickTarget); + await expect(outsideClicks[1]?.path).toContain(button); + await expect(outsideClicks[1]?.path).toContain(host); }, }; function NestedRootsHarness(): React.ReactNode { + const evidenceRef = useRef(null); + return ( - -
- -
-
+
{ + if (evidenceRef.current) { + evidenceRef.current.lightDomAncestorClickCount = + (evidenceRef.current.lightDomAncestorClickCount ?? 0) + 1; + } + }} + > + +
{ + if (evidenceRef.current) { + evidenceRef.current.outerShadowAncestorClickCount = + (evidenceRef.current.outerShadowAncestorClickCount ?? 0) + 1; + } + }} + > + +
+
+
+
); } @@ -370,6 +530,11 @@ export const NestedRootsRequireTraversalAndRetargetAtEveryBoundary: Story = { render: () => , play: async ({ canvasElement }) => { const outerHost = await requireShadowHost(canvasElement); + const evidence = requireElement( + canvasElement, + '[data-testid="nested-roots-evidence"]', + 'nested roots evidence not rendered', + ); const outerRoot = outerHost.shadowRoot!; const outerObserver = await waitForElement( outerRoot, @@ -409,6 +574,8 @@ export const NestedRootsRequireTraversalAndRetargetAtEveryBoundary: Story = { await userEvent.click(button); + await expect(evidence.lightDomAncestorClickCount).toBe(1); + await expect(evidence.outerShadowAncestorClickCount).toBe(1); await expect(outerScopeTarget).toBe(innerHost); await expect(documentScopeTarget).toBe(outerHost); await expect(composedPath[0]).toBe(button); diff --git a/packages/ui/src/lib/shadow-root-host.tsx b/packages/ui/src/lib/shadow-root-host.tsx index 33911d87..e5213dda 100644 --- a/packages/ui/src/lib/shadow-root-host.tsx +++ b/packages/ui/src/lib/shadow-root-host.tsx @@ -9,6 +9,7 @@ import { useRef, useState, type ReactNode, + type SyntheticEvent, } from 'react'; import { createPortal } from 'react-dom'; @@ -212,6 +213,18 @@ export function ShadowRootHost({ const setHostRef = useCallback((node: HTMLElement | null): void => { hostRef.current = node; }, []); + const stopDuplicateReactPropagation = useCallback((event: SyntheticEvent): void => { + const nativeEvent = event.nativeEvent; + const host = event.currentTarget; + const origin = nativeEvent.composedPath()[0]; + const isRetargetedHostTraversal = nativeEvent.target === host && origin !== host; + if (!isRetargetedHostTraversal) return; + + // A portal event follows the React tree once from inside the shadow root, + // then the same native event reaches the application root after retargeting. + // Stop only React's second logical traversal so native propagation remains intact. + event.isPropagationStopped = () => true; + }, []); const hideIfIdle = useCallback( (): void => { @@ -421,7 +434,13 @@ export function ShadowRootHost({ }, [shrinkableBlockHost]); return ( - + {shadowRoot ? createPortal(