Skip to content

fix(trigger): avoid flushSync for synchronous-call dedup - #622

Open
yezhonghu0503 wants to merge 1 commit into
react-component:masterfrom
yezhonghu0503:fix/avoid-flushsync-in-internal-trigger-open
Open

fix(trigger): avoid flushSync for synchronous-call dedup#622
yezhonghu0503 wants to merge 1 commit into
react-component:masterfrom
yezhonghu0503:fix/avoid-flushsync-in-internal-trigger-open

Conversation

@yezhonghu0503

@yezhonghu0503yezhonghu0503 commented Jun 1, 2026

Copy link
Copy Markdown

Summary

internalTriggerOpen wraps setInternalOpen / onOpenChange / onPopupVisibleChange in flushSync (introduced in #601 to dedup multi-event interactions like pointerenter + focus). Under React 19 that emits

flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering. Consider moving this call to a scheduler task or micro task.

whenever internalTriggerOpen is reached from inside a render/commit phase. The reproduction in the linked antd issue is clicking a <Tooltip trigger="focus">-wrapped button that also opens a Modal:

  1. Click handler updates Modal state — React enters its render phase.
  2. The focus event in the same event batch routes into Trigger's internalTriggerOpen.
  3. flushSync then fires inside the render → warning.

The dedup is necessary (without it, both events would dispatch onOpenChange(true) because state updates are async, so the second call would still see the stale mergedOpen), but it does not need to use flushSync.

What this PR does

Replaces the flushSync gate with a single useRef (lastDispatchedOpenRef) that records the last value internalTriggerOpen synchronously dispatched. Subsequent calls in the same batch compare against the ref instead of state, so dedup still works without forcing a sync render.

A useLayoutEffect keeps the ref in sync with mergedOpen after each commit, so:

- import { flushSync } from 'react-dom';-- const internalTriggerOpen = useEvent((nextOpen: boolean) => {- flushSync(() => {- if (mergedOpen !== nextOpen) {- setInternalOpen(nextOpen);- onOpenChange?.(nextOpen);- onPopupVisibleChange?.(nextOpen);- }- });- });+ const lastDispatchedOpenRef = React.useRef(mergedOpen);++ useLayoutEffect(() => {+ lastDispatchedOpenRef.current = mergedOpen;+ }, [mergedOpen]);++ const internalTriggerOpen = useEvent((nextOpen: boolean) => {+ if (lastDispatchedOpenRef.current !== nextOpen) {+ lastDispatchedOpenRef.current = nextOpen;+ setInternalOpen(nextOpen);+ onOpenChange?.(nextOpen);+ onPopupVisibleChange?.(nextOpen);+ }+ });

Tests

  • tests/open-change.test.tsx (added in fix(trigger): avoid render-based reset for interaction-level deduplication #601): both dedup cases (pointerenter+focus, pointerleave+blur) keep passing — onOpenChange is still called exactly once per interaction batch.
  • New tests/no-flush-sync-warning.test.tsx:
    1. Renders a component that fires focus on a Trigger target from inside a React effect, then asserts no flushSync was called from inside a lifecycle warning landed on console.error. Verified to fail on master and pass on this branch.
    2. Structural guard: src/index.tsx no longer imports or calls flushSync (comments mentioning it are stripped before the regex check so the explanatory comment can stay).

Full suite: npm test → 18 suites / 132 tests passing (1 pre-existing skip).

Refs

AI disclosure

Claude assisted with the regression hunt (locating #601 as the introduction point) and helped draft the test scaffolding. The fix design (ref + useLayoutEffect sync) and the wording above are reviewed; the test was independently verified to fail on the pre-fix code and pass after, locally.

Summary by CodeRabbit

发布说明

  • Bug 修复

    • 修复 React 19 下组件触发 flushSync 警告的问题。
    • 优化打开/关闭状态更新与去重逻辑,避免同一事件批次重复触发状态通知。
    • 改善受控模式下的状态同步,提升组件行为一致性。
  • 测试

    • 新增回归测试,验证 React 19 兼容性及状态更新流程。

@coderabbitai

coderabbitaiBot commented Jun 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9cf59425-85b1-4f8c-a482-2de228e40f3d

📥 Commits

Reviewing files that changed from the base of the PR and between 6a06a13 and 29ec54e.

📒 Files selected for processing (1)
  • src/index.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/index.tsx

Walkthrough

该 PR 移除 flushSync,并使用 lastDispatchedOpenRef 去重 open 状态派发。useLayoutEffect 会在 rawOpen 变化后同步去重基线。新增测试覆盖 React 19 警告和源码约束。

Changes

移除 flushSync 并重构去重逻辑

Layer / File(s)Summary
添加 ref 和 Hook 维护去重基线
src/index.tsx
新增 lastDispatchedOpenRef,并在 rawOpen 变化后通过 useLayoutEffect 同步该 ref。
移除 flushSync 并重写去重比较逻辑
src/index.tsx
移除 flushSync 导入。internalTriggerOpen 仅在 nextOpen 未被派发时更新状态并调用两个 open 变化回调。
添加回归测试
tests/no-flush-sync-warning.test.tsx
验证 React 19 流程不产生 flushSync 警告,并通过源码检查禁止 flushSync 导入和调用。

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
participant Trigger
participant React
participant OpenCallbacks
Trigger->>Trigger: 比较并记录 nextOpen
Trigger->>React: 更新内部 open 状态
React-->>Trigger: 提交 rawOpen 变化
Trigger->>OpenCallbacks: 调用 onOpenChange 和 onPopupVisibleChange
Loading

Poem

🐰 ref 记下 open 的脚步,

重复派发不再发生。
flushSync 悄然离场,
React 19 安静提交。
回调沿着新路径到达。

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 0.00% which is insufficient. The required threshold is 80.00%.Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ Passed标题准确概括了主要变更:移除 flushSync,并使用同步调用去重机制。
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assistgemini-code-assistBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request replaces the use of flushSync in src/index.tsx with a ref-based tracking mechanism (lastDispatchedOpenRef) to avoid React 19 warnings when triggering state updates during a render or commit phase. It also adds regression tests to ensure no warnings are emitted and that flushSync is not imported. The reviewer identified a critical issue in controlled mode: if a parent component rejects or ignores the onOpenChange callback, the tracking ref gets stuck in an inconsistent state, preventing subsequent interactions. A code suggestion was provided to reset the ref to the last committed state using a microtask.

Comment threadsrc/index.tsx Outdated
Comment on lines 396 to 412
// Keep the ref in sync with `mergedOpen` after each render so that
// controlled updates from outside (or any internal state change that
// already committed) reset the dedup baseline. This preserves the
// behaviour fixed in #601 where the dedup state could leak across user
// interactions in controlled mode without re-renders.
useLayoutEffect(() => {
lastDispatchedOpenRef.current = mergedOpen;
}, [mergedOpen]);

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If Trigger is used in controlled mode (where popupVisible is controlled by the parent), and the parent component decides to ignore or reject the onOpenChange(true) call (for example, due to custom validation or conditional logic), mergedOpen will remain false.

Because mergedOpen remains false, the useLayoutEffect (which has [mergedOpen] as a dependency) will not run, and the ref lastDispatchedOpenRef.current will remain stuck at true. Consequently, any subsequent user interactions attempting to open the trigger (calling internalTriggerOpen(true)) will be silently ignored because lastDispatchedOpenRef.current !== nextOpen evaluates to false (true !== true). This completely breaks the ability to retry opening the trigger in controlled mode.

To fix this, we can schedule a microtask to reset lastDispatchedOpenRef.current back to the last committed state (openRef.current) after the current event batch/tick completes. This ensures that if the state update is rejected or ignored, subsequent interactions can still trigger the callbacks, while still successfully deduplicating synchronous events within the same batch.

 // Keep the ref in sync with `mergedOpen` after each render so that
// controlled updates from outside (or any internal state change that
// already committed) reset the dedup baseline. This preserves the
// behaviour fixed in #601 where the dedup state could leak across user
// interactions in controlled mode without re-renders.
useLayoutEffect(() => {
lastDispatchedOpenRef.current = mergedOpen;
}, [mergedOpen]);
const internalTriggerOpen = useEvent((nextOpen: boolean) => {
if (lastDispatchedOpenRef.current !== nextOpen) {
lastDispatchedOpenRef.current = nextOpen;
setInternalOpen(nextOpen);
onOpenChange?.(nextOpen);
onPopupVisibleChange?.(nextOpen);
// Reset the ref to the last committed state after the current event batch/tick.
// This ensures that if the state update is rejected or ignored in controlled mode,
// subsequent interactions can still trigger the callbacks.
Promise.resolve().then(() => {
lastDispatchedOpenRef.current = openRef.current;
});
}
});

@coderabbitaicoderabbitaiBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/no-flush-sync-warning.test.tsx (1)

124-142: 💤 Low value

结构性守卫检查范围较宽

Line 139 的正则 /from\s+['"]react-dom['"]/ 会阻止任何 react-dom 导入,不仅限于 flushSync。如果将来有人需要添加其他合法的 react-dom 导入(如 createPortal),此测试会误报失败。

考虑到当前 src/index.tsx 通过 @rc-component/portal 封装来避免直接依赖 react-dom,且注释已说明这是"soft guard"用于触发审查,现有方案可以接受。如需更精确的检查,可改为:

expect(code).not.toMatch(/\bflushSync\b/);

这样只检查 flushSync 标识符,不会影响其他可能的 react-dom 导入。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tests/no-flush-sync-warning.test.tsx` around lines 124 - 142, The structural
guard test named "does not import flushSync from react-dom (structural guard)"
is too broad because the current assertion that checks for any "react-dom"
import will false-positive if other react-dom APIs are added; update the test by
removing or replacing the assertion that inspects imports (the expectation
against the regex matching a react-dom import) and instead assert only that the
source (the variable named code) does not contain the identifier "flushSync"
(i.e., keep the expectation that checks for absence of flushSync and drop the
generic react-dom import check) so the test only flags use of flushSync without
blocking other valid react-dom imports.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/no-flush-sync-warning.test.tsx`:
- Around line 124-142: The structural guard test named "does not import
flushSync from react-dom (structural guard)" is too broad because the current
assertion that checks for any "react-dom" import will false-positive if other
react-dom APIs are added; update the test by removing or replacing the assertion
that inspects imports (the expectation against the regex matching a react-dom
import) and instead assert only that the source (the variable named code) does
not contain the identifier "flushSync" (i.e., keep the expectation that checks
for absence of flushSync and drop the generic react-dom import check) so the
test only flags use of flushSync without blocking other valid react-dom imports.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d1e16d25-44dd-4b13-b7e4-22d0abcc198f

📥 Commits

Reviewing files that changed from the base of the PR and between 220358d and 6a06a13.

📒 Files selected for processing (2)
  • src/index.tsx
  • tests/no-flush-sync-warning.test.tsx

@codecov

codecovBot commented Jun 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.28%. Comparing base (220358d) to head (6a06a13).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@ Coverage Diff @@## master #622 +/- ##
=======================================
Coverage 97.28% 97.28% =======================================
Files 17 17 Lines 956 959 +3 Branches 268 278 +10 =======================================
+ Hits 930 933 +3 
Misses 26 26 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

`internalTriggerOpen` wrapped `setInternalOpen` / `onOpenChange` /
`onPopupVisibleChange` in `flushSync` (introduced in react-component#601) to dedup
within a single user interaction batch, because reading `mergedOpen`
between two synchronous calls would otherwise see the stale value.
Under React 19 that emits
flushSync was called from inside a lifecycle method. React cannot
flush when React is already rendering.
whenever `internalTriggerOpen` is reached from inside a render/commit —
for example clicking a `<Tooltip trigger="focus">`-wrapped button that
opens a Modal: the click updates Modal state (entering React's render
phase) and the focus event in the same batch routes into Trigger's
`internalTriggerOpen`, so `flushSync` fires mid-render.
Replace the flushSync gate with a single `useRef` that tracks the last
synchronously dispatched `nextOpen`, plus a `useLayoutEffect` that
syncs that ref to `mergedOpen` after each commit so controlled updates
from outside (and the `lastTriggerRef`-leak case react-component#601 originally fixed)
remain handled without depending on a render reset.
Adds `tests/no-flush-sync-warning.test.tsx` covering:
- No `flushSync was called from inside a lifecycle` warning when open
is triggered from inside a commit (the antd#57789 scenario).
- Structural guard: `src/index.tsx` no longer imports or calls
`flushSync`.
Existing `tests/open-change.test.tsx` (the dedup coverage added in
blur dedup behaviour is preserved.
Refs ant-design/ant-design#57789
@yezhonghu0503
yezhonghu0503force-pushed the fix/avoid-flushsync-in-internal-trigger-open branch from 6a06a13 to 29ec54eCompareAugust 11, 2026 02:08
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

@hippye99 is attempting to deploy a commit to the afc163's projects Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@yezhonghu0503@hippye99