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
Copy link
Copy Markdown

@yezhonghu0503 yezhonghu0503 commented Jun 1, 2026

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 下的兼容性

`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
react-component#601) keeps passing, so the synchronous pointer+focus / pointerleave+
blur dedup behaviour is preserved.

Refs ant-design/ant-design#57789
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 1, 2026

Review Change Stack

走查概览

此 PR 移除了 react-dom 中的 flushSync 依赖,并使用 ref 追踪已派发状态来取代 flushSync 包裹的同步比较,从而在 React 19 中避免生命周期警告。同时添加回归测试验证去重逻辑与 React 兼容性。

变更

移除 flushSync 并重构去重逻辑

Layer / File(s) 摘要
添加 ref 和 Hook 维护去重基线
src/index.tsx
新增 lastDispatchedOpenRef 用于记录最近一次派发的 nextOpen 值,并通过 useLayoutEffectmergedOpen 提交后同步更新该 ref,确保受控模式下外部更新能正确重置去重基线。
移除 flushSync 并重写去重比较逻辑
src/index.tsx
从导入中移除 flushSync,重写 internalTriggerOpen 改用 lastDispatchedOpenRef.currentnextOpen 比较来判断是否派发状态更新和回调,避免在 React 生命周期中调用 flushSync
添加 flushSync 移除的回归测试
tests/no-flush-sync-warning.test.tsx
新增 Trigger.NoFlushSyncWarning 测试套件,覆盖 React 19 下的 flushSync 警告回归,并通过源码静态检查确保不存在 flushSync 导入与调用。

相关 PR

  • react-component/trigger#523:同样修改了 internalTriggerOpen 的派发流程,针对回调调用逻辑的调整。

  • react-component/trigger#601:直接相关的 PR,同样修改了 internalTriggerOpen 的去重逻辑,使用不同的追踪 ref 和时序机制。

建议审查者

  • zombieJ
  • MadCcc

🐰 一束 ref 之光,驱散 flushSync 的烦恼,
在批次的边界中,去重悄然流淌,
React 19 的和谐,由此始而臻善。
无需同步的强制,状态与生命周期相安无事。


🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring 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 name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 标题清晰准确地总结了主要变化:移除 flushSync 并采用基于 ref 的同步去重机制,这正是本次更改的核心目的。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

src/index.tsx

ESLint skipped: missing config or dependency (missing-dependency). The ESLint configuration references a package that is not available in the sandbox.

tests/no-flush-sync-warning.test.tsx

ESLint skipped: the ESLint configuration for this file references a package that is not available in the sandbox.


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 and usage tips.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

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 thread src/index.tsx
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;
        });
      }
    });

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

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

Sign up for free to 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