From f20bed8cdcc0ee69fcbe86f3236540a2100263c5 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 08:53:28 -0700 Subject: [PATCH] refactor(ModalAnnounceProvider): watch presentation through the shared MutationObserver The provider's open/inert/aria-hidden watch now registers through observeMutations, Core's one observer per set of options, instead of a raw MutationObserver per open modal. The session still starts at the open intent and ends at the real close or unmount (runtime INV5 test unchanged). Test: two open modals register both dialogs with one observer. Co-authored-by: imdreamrunner --- .changeset/modal-announce-shared-observer.md | 7 ++++ .../core/src/hooks/ModalAnnounceProvider.tsx | 21 ++++++----- .../core/src/hooks/useAnnounce.modal.test.tsx | 36 +++++++++++++++++++ .../core/src/utils/sharedMutationObserver.ts | 4 +-- 4 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 .changeset/modal-announce-shared-observer.md diff --git a/.changeset/modal-announce-shared-observer.md b/.changeset/modal-announce-shared-observer.md new file mode 100644 index 0000000000000..8b2091c5efb03 --- /dev/null +++ b/.changeset/modal-announce-shared-observer.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[chore] Internal: the announcement routing inside open modals watches each dialog's `open`, `inert` and `aria-hidden` through Core's shared MutationObserver, so any number of open modals use one observer instead of one each. Routing is unchanged. + +@imdreamrunner diff --git a/packages/core/src/hooks/ModalAnnounceProvider.tsx b/packages/core/src/hooks/ModalAnnounceProvider.tsx index 4453193e6e366..95816332141eb 100644 --- a/packages/core/src/hooks/ModalAnnounceProvider.tsx +++ b/packages/core/src/hooks/ModalAnnounceProvider.tsx @@ -4,8 +4,8 @@ /** * @file ModalAnnounceProvider.tsx - * @input Uses React context, effects and the exposure and delivery - * internals of useAnnounce + * @input Uses React context, effects, the exposure and delivery internals of + * useAnnounce, and the shared MutationObserver * @output Exports ModalAnnounceProvider, the element every Astryx component * that owns a native renders around its content * @position Core a11y internal (spec:AST-086 FR14). Its own module so only @@ -32,6 +32,7 @@ import { } from './modalAnnounce'; import {ModalAnnounceContext, type ModalAnnounceScope} from './useAnnounce'; import {useIsomorphicLayoutEffect} from './useIsomorphicLayoutEffect'; +import {observeMutations} from '../utils/sharedMutationObserver'; /** * Rendered by every Astryx component that owns a native `` (Dialog, @@ -97,11 +98,11 @@ export function ModalAnnounceProvider({ notePresented(dialog); let wasOpen = dialog.open; const end = () => { - presentation.disconnect(); + stop(); forgetPresented(dialog); sessionRef.current = null; }; - const stop = () => presentation.disconnect(); + const stop = () => unobserve(); const onPresentationChange = () => { // The order is the top-layer order: only `open` being set moves a // dialog up it. A dialog turning inert or aria-hidden for its exit @@ -126,11 +127,13 @@ export function ModalAnnounceProvider({ end(); } }; - const presentation = new MutationObserver(onPresentationChange); - presentation.observe(dialog, { - attributes: true, - attributeFilter: ['open', 'inert', 'aria-hidden'], - }); + // Core's shared observer watches every open modal's presentation: one + // observer for all of them, not one per open modal. + const unobserve = observeMutations( + dialog, + {attributes: true, attributeFilter: ['open', 'inert', 'aria-hidden']}, + onPresentationChange, + ); sessionRef.current = {dialog, stop}; }, [dialogRef, isOpen]); useEffect( diff --git a/packages/core/src/hooks/useAnnounce.modal.test.tsx b/packages/core/src/hooks/useAnnounce.modal.test.tsx index 2110a97dbf021..ff33d672c8af3 100644 --- a/packages/core/src/hooks/useAnnounce.modal.test.tsx +++ b/packages/core/src/hooks/useAnnounce.modal.test.tsx @@ -266,6 +266,42 @@ describe('useAnnounce inside Astryx modals (spec:AST-086 FR14)', () => { } }); + it('watches every open modal through one shared observer', async () => { + const isPresentationWatch = (options?: MutationObserverInit) => + options?.attributeFilter?.join() === 'open,inert,aria-hidden'; + const watchers = new Set(); + const watched: Node[] = []; + const Original = globalThis.MutationObserver; + class Counting extends Original { + observe(target: Node, options?: MutationObserverInit) { + if (isPresentationWatch(options)) { + watchers.add(this); + watched.push(target); + } + super.observe(target, options); + } + } + vi.stubGlobal('MutationObserver', Counting); + try { + render( + {}} aria-label="Lower"> + + {}} aria-label="Upper"> + + + , + ); + await nextFrame(); + expect(watched).toEqual([ + screen.getByRole('dialog', {name: 'Upper'}), + screen.getByRole('dialog', {name: 'Lower'}), + ]); + expect(watchers.size).toBe(1); + } finally { + vi.unstubAllGlobals(); + } + }); + it('routes a caller inside an open Lightbox into its dialog', async () => { // Lightbox renders no children; a caption is content inside its dialog. render( diff --git a/packages/core/src/utils/sharedMutationObserver.ts b/packages/core/src/utils/sharedMutationObserver.ts index 90163384d088c..a58ecb24de468 100644 --- a/packages/core/src/utils/sharedMutationObserver.ts +++ b/packages/core/src/utils/sharedMutationObserver.ts @@ -7,8 +7,8 @@ * @input MutationObserver API * @output Exports observeMutations (one shared observer per set of options) * and observeContent, its text-and-children form - * @position Internal utility; consumed by useTruncation, hitArea and AppShell's - * main-overflow observer. The mutation counterpart of sharedResizeObserver: + * @position Internal utility; consumed by useTruncation, hitArea, AppShell's + * main-overflow observer and ModalAnnounceProvider's presentation watch. The mutation counterpart of sharedResizeObserver: * one observer for every element observed with the same options, so a * repeated cell, row or region does not create one observer per instance. */