Skip to content

Commit 7d62b35

Browse files
committed
[popups] Don't let a late trigger claim a programmatically-opened popup
A trigger that mounts while a popup is already open (via a handle's openWithPayload/open(null)) would claim ownership and overwrite the programmatic payload. Track openedWithoutTrigger in the popup store and skip the implicit, registration-time, and single-trigger ARIA (aria-controls) claim paths for such opens; reset the flag on close so a later non-imperative open (controlled/defaultOpen) isn't affected. DialogHandle.open records trigger intent so handle.open(id) before mount still associates. Adds regression tests for payload preservation and the absent ARIA association. (B5)
1 parent 3c5e55f commit 7d62b35

4 files changed

Lines changed: 110 additions & 4 deletions

File tree

packages/react/src/dialog/root/DialogRoot.detached-triggers.test.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,5 +787,78 @@ describe('<Dialog.Root />', () => {
787787
expect(screen.queryByRole('dialog')).toBe(null);
788788
});
789789
});
790+
791+
it('does not overwrite a programmatic payload when a trigger mounts while open', async () => {
792+
const dialog = Dialog.createHandle<number>();
793+
794+
function App({ showTrigger }: { showTrigger: boolean }) {
795+
return (
796+
<div>
797+
{showTrigger && (
798+
<Dialog.Trigger handle={dialog} id="late-trigger" payload={42}>
799+
Late trigger
800+
</Dialog.Trigger>
801+
)}
802+
<Dialog.Root handle={dialog}>
803+
{({ payload }: { payload: number | undefined }) => (
804+
<Dialog.Portal>
805+
<Dialog.Popup data-testid="content">{payload}</Dialog.Popup>
806+
</Dialog.Portal>
807+
)}
808+
</Dialog.Root>
809+
</div>
810+
);
811+
}
812+
813+
const { setProps } = await render(<App showTrigger={false} />);
814+
815+
await act(() => dialog.openWithPayload(8));
816+
await waitFor(() => {
817+
expect(screen.queryByRole('dialog')).not.toBe(null);
818+
});
819+
expect(screen.getByTestId('content').textContent).toBe('8');
820+
821+
// Mounting a trigger after the dialog was opened programmatically must not overwrite
822+
// the payload set via `openWithPayload`.
823+
await setProps({ showTrigger: true });
824+
825+
expect(screen.getByTestId('content').textContent).toBe('8');
826+
});
827+
828+
it('does not associate a late single trigger with a programmatically-opened dialog', async () => {
829+
const dialog = Dialog.createHandle<number>();
830+
831+
function App({ showTrigger }: { showTrigger: boolean }) {
832+
return (
833+
<div>
834+
{showTrigger && (
835+
<Dialog.Trigger data-testid="late-trigger" handle={dialog} id="late-trigger">
836+
Late trigger
837+
</Dialog.Trigger>
838+
)}
839+
<Dialog.Root handle={dialog} modal={false}>
840+
<Dialog.Portal>
841+
<Dialog.Popup data-testid="content">Content</Dialog.Popup>
842+
</Dialog.Portal>
843+
</Dialog.Root>
844+
</div>
845+
);
846+
}
847+
848+
const { setProps } = await render(<App showTrigger={false} />);
849+
850+
await act(() => dialog.openWithPayload(1));
851+
await waitFor(() => {
852+
expect(screen.queryByRole('dialog')).not.toBe(null);
853+
});
854+
855+
await setProps({ showTrigger: true });
856+
857+
const trigger = screen.getByTestId('late-trigger');
858+
// The dialog was opened without a trigger, so a late single trigger must not claim an
859+
// ARIA relationship (aria-controls / aria-expanded) to a popup it didn't open.
860+
expect(trigger).not.toHaveAttribute('aria-controls');
861+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
862+
});
790863
});
791864
});

packages/react/src/dialog/store/DialogHandle.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ export class DialogHandle<Payload> {
4141
true,
4242
createChangeEventDetails(REASONS.imperativeAction, undefined, triggerElement),
4343
);
44+
45+
if (triggerId && !triggerElement) {
46+
// The intended trigger hasn't mounted yet. Record the association so it is claimed
47+
// (with its payload) once it registers, instead of being treated as a trigger-less open.
48+
this.store.update({ activeTriggerId: triggerId, openedWithoutTrigger: false });
49+
}
4450
}
4551

4652
/**

packages/react/src/utils/popups/popupStoreUtils.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,12 @@ export function setPopupOpenState(
136136

137137
const triggerId = trigger?.id ?? null;
138138

139+
if (open) {
140+
// Track whether this open was initiated without a trigger (e.g. `openWithPayload`), so a
141+
// later-mounting trigger doesn't claim ownership and overwrite programmatically set state.
142+
state.openedWithoutTrigger = trigger == null;
143+
}
144+
139145
// If a popup is closing, the `trigger` may be undefined.
140146
// We want to keep the previous value so that exit animations are played and focus is returned correctly.
141147
if (triggerId || open) {
@@ -209,10 +215,11 @@ export function useTriggerDataForwarding<State extends PopupStoreState<unknown>>
209215
return;
210216
}
211217

212-
if (activeTriggerId == null && open) {
218+
if (activeTriggerId == null && open && !store.state.openedWithoutTrigger) {
213219
// If a popup is already open, a detached trigger can mount before any active trigger
214220
// has been established. Claim the first registered trigger so trigger-owned focus
215-
// management and ARIA relationships work.
221+
// management and ARIA relationships work. Popups opened imperatively without a trigger
222+
// (`openedWithoutTrigger`) are excluded so a late trigger can't overwrite their payload.
216223
store.update({
217224
activeTriggerId: triggerId,
218225
activeTriggerElement: element,
@@ -270,6 +277,12 @@ export function useImplicitActiveTrigger<State extends PopupStoreState<unknown>>
270277
if (store.state.triggerCount !== 0) {
271278
store.set('triggerCount', 0);
272279
}
280+
// Reset the triggerless-open flag once closed so a later open through a path that
281+
// doesn't run `setPopupOpenState` (e.g. controlled `open`/`defaultOpen`) isn't
282+
// suppressed by a stale value.
283+
if (store.state.openedWithoutTrigger) {
284+
store.set('openedWithoutTrigger', false);
285+
}
273286
return;
274287
}
275288

@@ -292,7 +305,12 @@ export function useImplicitActiveTrigger<State extends PopupStoreState<unknown>>
292305
}
293306
}
294307

295-
if (!lostActiveTriggerId && !activeTriggerId && triggerCount === 1) {
308+
if (
309+
!lostActiveTriggerId &&
310+
!activeTriggerId &&
311+
triggerCount === 1 &&
312+
!store.state.openedWithoutTrigger
313+
) {
296314
const iteratorResult = store.context.triggerElements.entries().next();
297315
if (!iteratorResult.done) {
298316
const [implicitTriggerId, implicitTriggerElement] = iteratorResult.value;

packages/react/src/utils/popups/store.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ export type PopupStoreState<Payload> = {
5454
* The currently active trigger DOM element.
5555
*/
5656
activeTriggerElement: Element | null;
57+
/**
58+
* Whether the popup was opened imperatively without a trigger (e.g. via a handle's
59+
* `openWithPayload`/`open(null)`). When set, a later-mounting trigger must not claim ownership
60+
* and overwrite programmatically set state such as `payload`.
61+
*/
62+
openedWithoutTrigger: boolean;
5763
/**
5864
* ID of the trigger (external prop).
5965
*/
@@ -94,6 +100,7 @@ export function createInitialPopupStoreState<Payload>(): PopupStoreState<Payload
94100
payload: undefined,
95101
activeTriggerId: null,
96102
activeTriggerElement: null,
103+
openedWithoutTrigger: false,
97104
triggerIdProp: undefined,
98105
popupElement: null,
99106
positionerElement: null,
@@ -168,7 +175,9 @@ function triggerOwnsOpenPopupOrIsOnlyTrigger(state: S, triggerId: string | undef
168175
triggerId !== undefined &&
169176
openSelector(state) &&
170177
activeTriggerIdSelector(state) == null &&
171-
state.triggerCount === 1
178+
state.triggerCount === 1 &&
179+
// A popup opened without a trigger must not be associated with a lone late trigger.
180+
!state.openedWithoutTrigger
172181
);
173182
}
174183

0 commit comments

Comments
 (0)