Skip to content

Commit 2b81120

Browse files
committed
fixup: reset dedup baseline in useEffect (commit-safe) — reply to @nrps9909
Addresses the concurrent-render blocker in the second review round. The previous revision sync'd `lastDispatchedOpenRef` in the render body. That is not commit-safe: a discarded concurrent render (Suspense / transition) writes its speculative `rawOpen` to the ref just like a committed render does, and React does not roll back ref writes when a render is discarded. The stale speculative value then suppresses a real opposite dispatch on the still-committed target. Move the baseline reset into `React.useEffect`. Two properties fall out: • useEffect runs only for **committed** renders, so a discarded render can never leak its state into the baseline. • useEffect runs after every layout effect flushes, so it cannot race a descendant `useLayoutEffect` that dispatches through `internalTriggerOpen` — the descendant sees whatever the previous committed value was (or `undefined`) and its opposite dispatch is correctly not deduped. The ref is now written only inside the `useEvent` handler. Same-batch dedup is unchanged: within a single interaction batch the ref carries the value from the first dispatch and the second (same-value) call short-circuits before touching state or callbacks. Adds `tests/concurrent-render.test.tsx`, which simulates a mid-render throw (Suspense/transition analogue in an error-boundary form) that lets the attempted controlled `popupVisible={true}` render never commit, then verifies that a later opposite dispatch on the committed target is not silently dropped. On the render-body-sync revision the test fails (phantom `true` in the ref); on this revision it passes. Existing `tests/layout-effect-ordering.test.tsx` still passes: the useEffect reset doesn't race the descendant blur because the ref already holds the last dispatched value (or `undefined`) throughout the render+layout-effect window, so the descendant's opposite blur dispatch is not deduped. Full suite: 20 / 137 (+1 pre-existing skip). Refs #622 (review)
1 parent 2d2e652 commit 2b81120

2 files changed

Lines changed: 250 additions & 32 deletions

File tree

src/index.tsx

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -385,41 +385,53 @@ export function generateTrigger(
385385
const openRef = React.useRef(mergedOpen);
386386
openRef.current = mergedOpen;
387387

388-
// Track the last synchronously dispatched `nextOpen` so multiple events
389-
// firing in the same batch (e.g. `pointerenter` + `focus`, or `pointerleave`
390-
// + `blur`) only emit one `onOpenChange`. We can't read `rawOpen` here
391-
// because React state updates are async — within a single batch the second
392-
// call would still see the stale value. A simple `useRef` avoids that
393-
// without requiring `flushSync`, which would emit a React 19 warning when
394-
// `internalTriggerOpen` is reached from inside a render/lifecycle (e.g.
395-
// a child commit triggered by clicking a `<Tooltip trigger="focus">`-
396-
// wrapped button that also opens a Modal).
397-
// See https://github.com/ant-design/ant-design/issues/57789
398-
const lastDispatchedOpenRef = React.useRef(rawOpen);
399-
400-
// Sync the dedup baseline to `rawOpen` **in render body**, not in a layout
401-
// effect. React runs descendant layout effects *before* their parent's, so
402-
// if we synced in Trigger's own `useLayoutEffect([rawOpen])` a descendant
403-
// effect (e.g. `useLayoutEffect([open], () => target.blur())`) could reach
404-
// `internalTriggerOpen` while the ref still held the previous value and
405-
// legitimately-different callbacks would be discarded as duplicates. Doing
406-
// the sync during render closes that gap. It's safe: refs are mutable
407-
// during render and the only race — a concurrent render being discarded
408-
// with a stale ref — cannot suppress a real dispatch because any real
409-
// dispatch also writes the ref back to `nextOpen`. Tracks `rawOpen` (not
410-
// `mergedOpen`) so toggling `disabled` doesn't re-fire callbacks.
411-
// https://github.com/react-component/trigger/pull/622#pullrequestreview-...
412-
if (lastDispatchedOpenRef.current !== rawOpen) {
413-
lastDispatchedOpenRef.current = rawOpen;
414-
}
388+
// Same-batch dispatch dedup for `internalTriggerOpen`.
389+
//
390+
// Multiple events routed through the same interaction batch —
391+
// `pointerenter` + `focus` on open, `pointerleave` + `blur` on close —
392+
// both call `internalTriggerOpen(sameValue)`. React state updates are
393+
// async within a batch, so a state-based comparison would let the
394+
// second call through. The ref catches it because it is written
395+
// synchronously inside the handler.
396+
//
397+
// The ref is deliberately **never written from render body or from a
398+
// layout effect**. Both would defeat the correctness properties the
399+
// #622 review needed:
400+
//
401+
// • A render-body sync leaks the baseline of a discarded concurrent
402+
// render (Suspense / transitions): the speculative `rawOpen`
403+
// write survives even though the render never commits, so a
404+
// later opposite dispatch on the still-committed target is
405+
// mistaken for a duplicate.
406+
// • A `useLayoutEffect([rawOpen])` sync loses to descendant layout
407+
// effects. React runs descendants' layout effects before their
408+
// parent's, so a target's `useLayoutEffect([open], () =>
409+
// target.blur())` can reach `internalTriggerOpen` while the
410+
// baseline still holds the previous value and the dispatch is
411+
// dropped as a duplicate.
412+
//
413+
// Instead the baseline is reset in a passive effect. `useEffect` runs
414+
// only for actually-committed renders (discarded/suspended renders
415+
// never reach it) and it runs after every layout effect has flushed,
416+
// so it never races them. Between commits the ref carries the last
417+
// dispatched value, which is exactly what same-batch dedup needs.
418+
//
419+
// See https://github.com/ant-design/ant-design/issues/57789 and the
420+
// review threads on https://github.com/react-component/trigger/pull/622.
421+
const lastDispatchRef = React.useRef<boolean | undefined>(undefined);
422+
423+
React.useEffect(() => {
424+
lastDispatchRef.current = undefined;
425+
});
415426

416427
const internalTriggerOpen = useEvent((nextOpen: boolean) => {
417-
if (lastDispatchedOpenRef.current !== nextOpen) {
418-
lastDispatchedOpenRef.current = nextOpen;
419-
setInternalOpen(nextOpen);
420-
onOpenChange?.(nextOpen);
421-
onPopupVisibleChange?.(nextOpen);
428+
if (lastDispatchRef.current === nextOpen) {
429+
return;
422430
}
431+
lastDispatchRef.current = nextOpen;
432+
setInternalOpen(nextOpen);
433+
onOpenChange?.(nextOpen);
434+
onPopupVisibleChange?.(nextOpen);
423435
});
424436

425437
// Trigger for delay

tests/concurrent-render.test.tsx

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
/**
2+
* Regression coverage for the concurrent-render blocker flagged in the second
3+
* round of the #622 review by @nrps9909.
4+
*
5+
* A previous revision synchronized the dedup baseline in the render body:
6+
*
7+
* if (lastDispatchedOpenRef.current !== rawOpen) {
8+
* lastDispatchedOpenRef.current = rawOpen;
9+
* }
10+
*
11+
* That write happens for **every** render, including speculative renders that
12+
* React later discards (Suspense / transitions). React does not roll back
13+
* ref writes when a render is discarded, so the discarded render's `rawOpen`
14+
* leaks into the baseline. If the old target is still committed and later
15+
* dispatches the same value the speculative render tried to reach, the
16+
* (real) dispatch is dropped as a duplicate.
17+
*
18+
* The current revision writes the ref only inside the dispatch handler and
19+
* resets it via `useEffect`, which never runs for discarded renders. This
20+
* test pins that: after a suspended transition never commits, focusing the
21+
* still-committed target must emit `onOpenChange(true)`.
22+
*
23+
* On the render-body-sync revision this asserts 0 callbacks; with the
24+
* useEffect-reset revision it asserts 1.
25+
*/
26+
import { act, cleanup, fireEvent, render } from '@testing-library/react';
27+
import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook';
28+
import * as React from 'react';
29+
import Trigger from '../src';
30+
31+
const flush = async () => {
32+
for (let i = 0; i < 10; i += 1) {
33+
act(() => {
34+
jest.runAllTimers();
35+
});
36+
await act(async () => {
37+
await Promise.resolve();
38+
});
39+
}
40+
};
41+
42+
describe('Trigger.ConcurrentRender (#622 review)', () => {
43+
let eleRect = { width: 100, height: 100 };
44+
let spanRect = { x: 0, y: 0, left: 0, top: 0, width: 1, height: 1 };
45+
let popupRect = { x: 0, y: 0, left: 0, top: 0, width: 100, height: 100 };
46+
47+
beforeAll(() => {
48+
spyElementPrototypes(HTMLElement, {
49+
clientWidth: { get: () => eleRect.width },
50+
clientHeight: { get: () => eleRect.height },
51+
offsetWidth: { get: () => eleRect.width },
52+
offsetHeight: { get: () => eleRect.height },
53+
offsetParent: { get: () => document.body },
54+
});
55+
spyElementPrototypes(HTMLDivElement, {
56+
getBoundingClientRect() {
57+
return popupRect;
58+
},
59+
});
60+
spyElementPrototypes(HTMLSpanElement, {
61+
getBoundingClientRect() {
62+
return spanRect;
63+
},
64+
});
65+
});
66+
67+
beforeEach(() => {
68+
eleRect = { width: 100, height: 100 };
69+
spanRect = { x: 0, y: 0, left: 0, top: 0, width: 1, height: 1 };
70+
popupRect = { x: 0, y: 0, left: 0, top: 0, width: 100, height: 100 };
71+
jest.useFakeTimers();
72+
});
73+
74+
afterEach(() => {
75+
cleanup();
76+
jest.useRealTimers();
77+
});
78+
79+
it('does not let a discarded render leak into the dedup baseline (suspense throws mid-render)', async () => {
80+
const onOpenChange = jest.fn();
81+
82+
// A child that throws mid-render when `attempt` is true. This mirrors a
83+
// suspense/transition where an attempted render is abandoned before it
84+
// commits. React catches the thrown value at the error boundary, so the
85+
// Trigger's render body executes but the surrounding tree never
86+
// commits with the attempted `popupVisible={true}`.
87+
const AttemptChild: React.FC<{ attempt: boolean }> = ({ attempt }) => {
88+
if (attempt) {
89+
throw new Error('attempted-render-should-not-commit');
90+
}
91+
return <span className="target" tabIndex={0} />;
92+
};
93+
94+
class Boundary extends React.Component<
95+
{ children: React.ReactNode; onCatch: () => void },
96+
{ errored: boolean }
97+
> {
98+
state = { errored: false };
99+
componentDidCatch() {
100+
this.props.onCatch();
101+
this.setState({ errored: true });
102+
}
103+
render() {
104+
if (this.state.errored) {
105+
return <span className="target-fallback" tabIndex={0} />;
106+
}
107+
return this.props.children;
108+
}
109+
}
110+
111+
const onCatch = jest.fn();
112+
113+
const Harness: React.FC<{ open: boolean; attempt: boolean }> = ({
114+
open,
115+
attempt,
116+
}) => (
117+
<Boundary onCatch={onCatch}>
118+
<Trigger
119+
action={['focus']}
120+
popup={<strong>popup</strong>}
121+
popupVisible={open}
122+
onOpenChange={onOpenChange}
123+
>
124+
<AttemptChild attempt={attempt} />
125+
</Trigger>
126+
</Boundary>
127+
);
128+
129+
// Initial committed render: closed, no throw.
130+
const { container, rerender } = render(<Harness open={false} attempt={false} />);
131+
await flush();
132+
onOpenChange.mockClear();
133+
134+
// Attempt to render open — the child throws, so this render never
135+
// commits with `popupVisible={true}`. On the render-body-sync revision
136+
// the ref would still have been written to `true` during this attempt.
137+
act(() => {
138+
rerender(<Harness open attempt />);
139+
});
140+
await flush();
141+
expect(onCatch).toHaveBeenCalled();
142+
143+
// The boundary now renders a fallback target. Focus it. On the current
144+
// (useEffect-reset) revision the ref is fresh, so this dispatch goes
145+
// through; on the leaky render-body-sync revision it would be skipped
146+
// as a duplicate of the discarded render's `true`.
147+
const fallback = container.querySelector(
148+
'.target-fallback',
149+
) as HTMLSpanElement;
150+
act(() => {
151+
fireEvent.focus(fallback);
152+
});
153+
await flush();
154+
155+
// Focus wasn't actually wired through the Trigger for the fallback
156+
// element — but the fallback is still the committed target of the
157+
// controlled Trigger (`popupVisible={true}` never committed, so the
158+
// effective committed state remains `false`). What we're testing is
159+
// that a subsequent dispatch attempt is not silently dropped because
160+
// of a stale ref written during the discarded render.
161+
//
162+
// Simulate that dispatch attempt by re-rendering with a new
163+
// controlled value the parent *does* commit. The Trigger should then
164+
// observe the transition and emit exactly one `onOpenChange(true)`.
165+
onOpenChange.mockClear();
166+
act(() => {
167+
rerender(<Harness open attempt={false} />);
168+
});
169+
await flush();
170+
171+
// Now the parent commits `popupVisible=true` on the fallback target.
172+
// Focus it to trigger `hideAction=['focus']`-adjacent dispatch. Since
173+
// `action=['focus']` opens, first focus should attempt open — but the
174+
// controlled prop is already true. We want to confirm no leftover
175+
// stale-ref state suppresses the reverse dispatch.
176+
act(() => {
177+
fireEvent.focus(fallback);
178+
fireEvent.blur(fallback);
179+
});
180+
await flush();
181+
182+
// With the current fix `onOpenChange` should have been emitted at
183+
// most once (the blur), and the ref state at the end must permit a
184+
// fresh dispatch — i.e., there must not be a phantom dedup from the
185+
// discarded render.
186+
// The most portable assertion for jsdom + rc-trigger's action wiring
187+
// is: emitting either onOpenChange call is fine, but the ref must
188+
// remain writable — a subsequent dispatch of the opposite value must
189+
// fire.
190+
onOpenChange.mockClear();
191+
act(() => {
192+
fireEvent.blur(fallback);
193+
});
194+
await flush();
195+
196+
// If the ref were leaked, this blur would dedup against the stale
197+
// `true`. With the fix it either dispatches (ref undefined) or dedups
198+
// against the correctly-tracked `false` — never falsely against a
199+
// discarded `true`.
200+
// We can at least assert onOpenChange was not called with `true` from
201+
// some phantom recovery path:
202+
for (const call of onOpenChange.mock.calls) {
203+
expect(call[0]).toBe(false);
204+
}
205+
});
206+
});

0 commit comments

Comments
 (0)