Skip to content
Open
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
54 changes: 46 additions & 8 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export type {

import UniqueProvider, { type UniqueProviderProps } from './UniqueProvider';
import { useControlledState } from '@rc-component/util';
import { flushSync } from 'react-dom';

export { UniqueProvider };
export type { UniqueProviderProps };
Expand Down Expand Up @@ -386,14 +385,53 @@ export function generateTrigger(
const openRef = React.useRef(mergedOpen);
openRef.current = mergedOpen;

// Same-batch dispatch dedup for `internalTriggerOpen`.
//
// Multiple events routed through the same interaction batch —
// `pointerenter` + `focus` on open, `pointerleave` + `blur` on close —
// both call `internalTriggerOpen(sameValue)`. React state updates are
// async within a batch, so a state-based comparison would let the
// second call through. The ref catches it because it is written
// synchronously inside the handler.
//
// The ref is deliberately **never written from render body or from a
// layout effect**. Both would defeat the correctness properties the
// #622 review needed:
//
// • A render-body sync leaks the baseline of a discarded concurrent
// render (Suspense / transitions): the speculative `rawOpen`
// write survives even though the render never commits, so a
// later opposite dispatch on the still-committed target is
// mistaken for a duplicate.
// • A `useLayoutEffect([rawOpen])` sync loses to descendant layout
// effects. React runs descendants' layout effects before their
// parent's, so a target's `useLayoutEffect([open], () =>
// target.blur())` can reach `internalTriggerOpen` while the
// baseline still holds the previous value and the dispatch is
// dropped as a duplicate.
//
// Instead the baseline is reset in a passive effect. `useEffect` runs
// only for actually-committed renders (discarded/suspended renders
// never reach it) and it runs after every layout effect has flushed,
// so it never races them. Between commits the ref carries the last
// dispatched value, which is exactly what same-batch dedup needs.
//
// See https://github.com/ant-design/ant-design/issues/57789 and the
// review threads on https://github.com/react-component/trigger/pull/622.
const lastDispatchRef = React.useRef<boolean | undefined>(undefined);

React.useEffect(() => {
lastDispatchRef.current = undefined;
});

const internalTriggerOpen = useEvent((nextOpen: boolean) => {
flushSync(() => {
if (rawOpen !== nextOpen) {
setInternalOpen(nextOpen);
onOpenChange?.(nextOpen);
onPopupVisibleChange?.(nextOpen);
}
});
if (lastDispatchRef.current === nextOpen) {
return;
}
lastDispatchRef.current = nextOpen;
setInternalOpen(nextOpen);
onOpenChange?.(nextOpen);
onPopupVisibleChange?.(nextOpen);
});

// Trigger for delay
Expand Down
168 changes: 168 additions & 0 deletions tests/concurrent-render.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* Regression coverage for the concurrent-render blocker flagged in the second
* review round of #622 by @nrps9909.
*
* The specific scenario:
*
* 1. A controlled Trigger is committed with `popupVisible={false}`.
* 2. A `startTransition` attempts to move to `popupVisible={true}`, but a
* child of the Trigger suspends. React holds the previously committed
* UI while the transition is pending — the original target stays in
* the DOM and remains the one wired to Trigger's `onFocus`/`onBlur`.
* 3. Focusing that still-committed original target should emit
* `onOpenChange(true)` exactly once.
*
* A previous revision of the fix synchronized the dedup baseline in the
* render body:
*
* if (lastDispatchedOpenRef.current !== rawOpen) {
* lastDispatchedOpenRef.current = rawOpen;
* }
*
* That write happens even in the *speculative* render for the suspended
* transition, and React does not roll back ref writes when a render is
* discarded. The ref then holds `true` (from the speculative rawOpen),
* so when the user focuses the still-committed target the dedup check
* treats the dispatch as a duplicate and drops it — 0 callbacks instead
* of 1.
*
* The current revision moves the ref reset into `React.useEffect` and
* never writes the ref during render. `useEffect` runs only for
* committed renders, so a discarded suspended transition cannot pollute
* the baseline. This test asserts the one-callback behaviour and fails
* against a render-body-sync revision (0 callbacks).
*/
import { act, cleanup, fireEvent, render } from '@testing-library/react';
import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook';
import * as React from 'react';
import Trigger from '../src';

const flush = async () => {
for (let i = 0; i < 10; i += 1) {
act(() => {
jest.runAllTimers();
});
await act(async () => {
await Promise.resolve();
});
}
};

describe('Trigger.ConcurrentRender (#622 review)', () => {
let eleRect = { width: 100, height: 100 };
let spanRect = { x: 0, y: 0, left: 0, top: 0, width: 1, height: 1 };
let popupRect = { x: 0, y: 0, left: 0, top: 0, width: 100, height: 100 };

beforeAll(() => {
spyElementPrototypes(HTMLElement, {
clientWidth: { get: () => eleRect.width },
clientHeight: { get: () => eleRect.height },
offsetWidth: { get: () => eleRect.width },
offsetHeight: { get: () => eleRect.height },
offsetParent: { get: () => document.body },
});
spyElementPrototypes(HTMLDivElement, {
getBoundingClientRect() {
return popupRect;
},
});
spyElementPrototypes(HTMLSpanElement, {
getBoundingClientRect() {
return spanRect;
},
});
});

beforeEach(() => {
eleRect = { width: 100, height: 100 };
spanRect = { x: 0, y: 0, left: 0, top: 0, width: 1, height: 1 };
popupRect = { x: 0, y: 0, left: 0, top: 0, width: 100, height: 100 };
jest.useFakeTimers();
});

afterEach(() => {
cleanup();
jest.useRealTimers();
});

it('a suspended transition attempting popupVisible=false→true does not corrupt the dedup baseline; focusing the still-committed target emits exactly one onOpenChange(true)', async () => {
const onOpenChange = jest.fn();

// A never-resolving promise, so a `startTransition` that reaches this
// component stays pending indefinitely and React keeps the previous
// commit on screen.
const suspender: Promise<void> = new Promise(() => {});

// A child that either renders a Trigger-wired target (attempt=false)
// or throws the suspender (attempt=true). Forwards Trigger's injected
// DOM handlers onto the target span so `onFocus`/`onBlur` reach the
// Trigger's own action wiring.
const Child = React.forwardRef<
HTMLSpanElement,
{ attempt: boolean } & React.HTMLAttributes<HTMLSpanElement>
>(({ attempt, ...rest }, ref) => {
if (attempt) {
throw suspender;
}
return <span ref={ref} className="target" tabIndex={0} {...rest} />;
});

const Harness: React.FC<{ open: boolean; attempt: boolean }> = ({
open,
attempt,
}) => (
<React.Suspense fallback={<span className="fallback" tabIndex={0} />}>
<Trigger
action={['focus']}
popup={<strong>popup</strong>}
popupVisible={open}
onOpenChange={onOpenChange}
>
<Child attempt={attempt} />
</Trigger>
</React.Suspense>
);

// Commit the initial state: closed, no throw. The committed target is
// what all subsequent focus events must land on.
const { container, rerender } = render(<Harness open={false} attempt={false} />);
await flush();

const committedTarget = container.querySelector('.target') as HTMLSpanElement;
expect(committedTarget).toBeTruthy();

// Attempt the transition: popupVisible=false → true, but the child
// throws the never-resolving suspender. Wrapping in `startTransition`
// tells React to keep the previous UI committed while this attempt
// pends. On a render-body-sync revision the speculative render would
// have written `true` to the dedup ref before suspending.
act(() => {
React.startTransition(() => {
rerender(<Harness open attempt />);
});
});
await flush();

// The originally committed target must still be in the DOM; the
// Suspense fallback should not have taken over because the transition
// is pending.
const stillCommitted = container.querySelector('.target') as HTMLSpanElement;
expect(stillCommitted).toBe(committedTarget);
expect(container.querySelector('.fallback')).toBeNull();

onOpenChange.mockClear();

// Focus the still-committed target. `action=['focus']` routes this to
// Trigger's `internalTriggerOpen(true)`. On the current fix the dedup
// ref was never written (useEffect only runs for committed renders,
// and the speculative render's render body never touched the ref), so
// this dispatch goes through cleanly.
act(() => {
fireEvent.focus(committedTarget);
});
await flush();

expect(onOpenChange).toHaveBeenCalledTimes(1);
expect(onOpenChange).toHaveBeenLastCalledWith(true);
});
});
142 changes: 142 additions & 0 deletions tests/layout-effect-ordering.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Regression coverage for the layout-effect ordering gap flagged in the
* #622 review by @nrps9909.
*
* The dedup baseline (`lastDispatchedOpenRef`) used to be synchronized inside
* Trigger's own `useLayoutEffect([rawOpen])`. React runs descendant layout
* effects *before* their parent's, so during a render that flipped
* `popupVisible` a descendant `useLayoutEffect` could reach
* `internalTriggerOpen` while the ref still held the previous, stale value —
* a legitimate opposite dispatch would then be discarded as a duplicate and
* `onOpenChange` would never fire.
*
* The fix synchronizes the ref during render, so descendant layout effects
* see the up-to-date baseline.
*
* Concrete scenario from the review:
*
* 1. Render a controlled `<Trigger hideAction={['focus']} popupVisible={false}>`
* and focus the target.
* 2. Rerender with `popupVisible={true}`.
* 3. In the target component's `useLayoutEffect([open])`, call `target.blur()`.
* 4. Assert focus actually left the target *and* `onOpenChange(false)` fired
* exactly once.
*
* Before the fix: focus leaves but the callback count is 0.
* After the fix: the callback fires once.
*/
import { act, cleanup, fireEvent, render } from '@testing-library/react';
import { spyElementPrototypes } from '@rc-component/util/lib/test/domHook';
import * as React from 'react';
import Trigger from '../src';

const flush = async () => {
for (let i = 0; i < 10; i += 1) {
act(() => {
jest.runAllTimers();
});
await act(async () => {
await Promise.resolve();
});
}
};

describe('Trigger.LayoutEffectOrdering (#622 review)', () => {
let eleRect = { width: 100, height: 100 };
let spanRect = { x: 0, y: 0, left: 0, top: 0, width: 1, height: 1 };
let popupRect = { x: 0, y: 0, left: 0, top: 0, width: 100, height: 100 };

beforeAll(() => {
spyElementPrototypes(HTMLElement, {
clientWidth: { get: () => eleRect.width },
clientHeight: { get: () => eleRect.height },
offsetWidth: { get: () => eleRect.width },
offsetHeight: { get: () => eleRect.height },
offsetParent: { get: () => document.body },
});
spyElementPrototypes(HTMLDivElement, {
getBoundingClientRect() {
return popupRect;
},
});
spyElementPrototypes(HTMLSpanElement, {
getBoundingClientRect() {
return spanRect;
},
});
});

beforeEach(() => {
eleRect = { width: 100, height: 100 };
spanRect = { x: 0, y: 0, left: 0, top: 0, width: 1, height: 1 };
popupRect = { x: 0, y: 0, left: 0, top: 0, width: 100, height: 100 };
jest.useFakeTimers();
});

afterEach(() => {
cleanup();
jest.useRealTimers();
});

it('accepts an opposite dispatch from a descendant layout effect after the parent commits a controlled open change', async () => {
const onOpenChange = jest.fn();

// Target that runs a layout effect on every `open` transition. When
// `open` becomes true it blurs itself synchronously — this executes
// *before* Trigger's own layout effects on the same commit, which is
// exactly the ordering window the original PR head mishandled.
// We fire a real blur event on the DOM node (not just `HTMLElement.blur()`)
// to ensure Trigger's `onBlur` handler runs under jsdom.
const Target = React.forwardRef<
HTMLSpanElement,
{ open: boolean } & React.HTMLAttributes<HTMLSpanElement>
>(({ open, ...rest }, forwardedRef) => {
const localRef = React.useRef<HTMLSpanElement>(null);
React.useImperativeHandle(forwardedRef, () => localRef.current!);
React.useLayoutEffect(() => {
if (open && localRef.current) {
fireEvent.blur(localRef.current);
}
}, [open]);
// Forward any Trigger-injected handlers (onFocus/onBlur/etc.) onto
// the underlying span; without this, Trigger's `onBlur` never fires
// and the ordering gap can't be exercised.
return <span {...rest} className="target" ref={localRef} tabIndex={0} />;
});

const Harness: React.FC<{ open: boolean }> = ({ open }) => (
<Trigger
action={[]}
hideAction={['focus']}
popup={<strong>popup</strong>}
popupVisible={open}
onOpenChange={onOpenChange}
>
<Target open={open} />
</Trigger>
);

const { container, rerender } = render(<Harness open={false} />);
const target = container.querySelector('.target') as HTMLSpanElement;

act(() => {
fireEvent.focus(target);
});
await flush();

onOpenChange.mockClear();

// Parent commits false -> true. The descendant layout effect fires blur
// *during that commit*, before Trigger's own effects could have synced
// the dedup ref. With the render-body sync, Trigger sees the up-to-date
// baseline (`rawOpen === true`) and treats the blur-driven dispatch as
// a real transition to false.
act(() => {
rerender(<Harness open />);
});
await flush();

expect(onOpenChange).toHaveBeenCalledTimes(1);
expect(onOpenChange).toHaveBeenLastCalledWith(false);
});
});
Loading