Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/modal-announce-shared-observer.md
Original file line number Diff line number Diff line change
@@ -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
21 changes: 12 additions & 9 deletions packages/core/src/hooks/ModalAnnounceProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dialog> renders around its content
* @position Core a11y internal (spec:AST-086 FR14). Its own module so only
Expand All @@ -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>` (Dialog,
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
36 changes: 36 additions & 0 deletions packages/core/src/hooks/useAnnounce.modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<MutationObserver>();
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(
<Dialog isOpen onOpenChange={() => {}} aria-label="Lower">
<Owner label="Lower say" message="Lower words" />
<Dialog isOpen onOpenChange={() => {}} aria-label="Upper">
<Owner label="Upper say" message="Upper words" />
</Dialog>
</Dialog>,
);
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(
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/utils/sharedMutationObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading