[tooltip] Reset preventUnmountOnClose on reopen#4885
Conversation
commit: |
Bundle size
PerformanceTotal duration: 1,191.82 ms -37.92 ms(-3.1%) | Renders: 50 (+0) | Paint: 1,796.01 ms -87.32 ms(-4.6%) No significant changes — details Check out the code infra dashboard for more information about this PR. |
✅ Deploy Preview for base-ui ready!Built without sensitive environment variables
To edit notification comments on pull requests, go to your Netlify project configuration. |
Codex Review (GPT-5.5)Reviewed the full PR diff for #4885 against freshly fetched 1. Bugs / Issues (None)I did not find any concrete branch-relevant bugs or regressions in the implementation. Root Cause & Patch AssessmentThe fix resets Repo Conventions / Cleanliness AssessmentThe change follows the repo’s hook utility guidance by using Test Coverage AssessmentI ran RecommendationApprove ✅ The patch addresses the root cause cleanly, the regression coverage matches the reported failure, and the targeted validation passed. |
…vent-unmount-reset
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 54f7fc63ae
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7bc2d7902c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const shouldClearPreventUnmountingOnClose = | ||
| open && !previousOpenRef.current && store.state.preventUnmountingOnClose; | ||
| const syncedPreventUnmountingOnClose = shouldClearPreventUnmountingOnClose | ||
| ? false | ||
| : preventUnmountingOnClose; |
There was a problem hiding this comment.
Clear prevented-close latch while still open
When a close request is canceled after calling details.preventUnmountOnClose(), setOpen returns before changing open, so the next render still has open === true and previousOpenRef.current === true. Because this branch only clears on a false→true transition, the preventUnmountingOnClose latch remains set while the popup stays open; a later normal close will then skip useOpenChangeComplete and leave the popup mounted even though no close was prevented. The previous open ? false : ... sync cleared this stale latch on any open render.
Useful? React with 👍 / 👎.
| ) { | ||
| const { mounted, setMounted, transitionStatus } = useTransitionStatus(open); | ||
| const preventUnmountingOnClose = store.useState('preventUnmountingOnClose'); | ||
| const previousOpenRef = React.useRef(false); |
There was a problem hiding this comment.
Any reason not to use useValueAsRef here?
flaviendelangle
left a comment
There was a problem hiding this comment.
PR #4885 Review Summary — [tooltip] Reset preventUnmountOnClose on reopen
Four specialized agents reviewed this PR in parallel. All ran type-checking and the relevant test suites (Tooltip, Popover, Menu, Dialog, AlertDialog, PreviewCard) — all pass.
Critical Issues
1. Test suppresses a real production React warning (silent-failure-hunter + code-reviewer + pr-test-analyzer)
File: packages/react/src/tooltip/root/TooltipRoot.test.tsx:341-353
The new tooltip test installs a console.error spy that silently swallows:
Cannot update a component (TooltipTrigger) while rendering a different component (TooltipRoot)
The silent-failure-hunter empirically verified the warning is real and stack-traced it to:
useOnFirstRenderatTooltipRoot.tsx:56-63synchronously callsstore.update({open: true, ...})during render- A detached
Tooltip.Triggersibling subscribed to the same store gets a setState-during-render notification — exactly the anti-pattern React's warning catches
The Popover version of the test avoids this entirely by placing the Trigger inside the Root. The same defect exists in PopoverRoot.tsx:46-53 and MenuRoot.tsx:115, but the tooltip test scenario is the only one that exposes it.
Risks: (a) The narrow substring filter ('Cannot update a component' + 'TooltipTrigger' + 'TooltipRoot') is broad enough to mask unrelated future regressions in this component pair. (b) Real users with detached triggers + shared handles + defaultOpen will see this warning in dev.
Recommendations:
- Preferred: Defer
useOnFirstRender'sstore.updateinto a layout effect to eliminate the warning at its root. - Minimum: Convert the suppression into an explicit
expect(consoleError).toHaveBeenCalledWith(...)assertion documenting the known limitation, so future divergence is caught.
2. Tooltip test runs 3× redundantly inside describe.for (code-reviewer)
File: packages/react/src/tooltip/root/TooltipRoot.test.tsx:339-439
The new "unmounts on a normal close after a prevented close and initially open remount" test is inside the describe.for([contained / detached / multiple detached triggers]) block (line 38), but it ignores the TestTooltip parameter and creates its own hardcoded structure. It executes the same code path three times, adding ~600ms with no extra coverage. Move it to a dedicated outer describe, or refactor to use TestTooltip.
Important Issues
3. Subscribed vs direct state read inconsistency (silent-failure-hunter + code-reviewer)
File: packages/react/src/utils/popups/popupStoreUtils.ts:266-272
const preventUnmountingOnClose = store.useState('preventUnmountingOnClose'); // subscribed
const shouldClearPreventUnmountingOnClose =
open && !previousOpenRef.current && store.state.preventUnmountingOnClose; // directBoth reads return the same value today, but mixing them is confusing and the direct store.state.x read during render bypasses the reactive subscription system. Use the subscribed value in both places.
4. Cross-component coverage gap (pr-test-analyzer)
The fix lives in shared useOpenStateTransitions (used by tooltip, popover, dialog, menu, preview-card). Tests are only added for tooltip + popover. Menu has a related test (MenuRoot.test.tsx:2318) but Dialog, AlertDialog, PreviewCard, and Drawer have no test for the defaultOpen remount scenario this fix targets. A future refactor that splits the hook could silently regress them.
5. Missing WHY comments (comment-analyzer)
Three load-bearing pieces of logic have no comments to defend them against future "cleanup":
previousOpenRef = React.useRef(false)(line 267): the literalfalseinitialization is the entire point of the fix — it makesdefaultOpen=trueremounts treated as open edges. A reader could "fix" it back touseRef(open)and break the regression test.- Why the clear happens during render (lines 267-272): the previous version did this in an effect; the new version intentionally moves it to render so
useOpenChangeComplete(line 297) sees the cleared value on the same render as the edge. Without a comment, this is invisible. - Relationship to
setPopupOpenState: the existing comment atpopupStoreUtils.ts:125("Opening starts a new close cycle…") documents the same invariant. The new code is a second enforcement for paths that bypasssetOpen(e.g.defaultOpenremount viauseOnFirstRender). This connection should be made explicit.
Suggestions
forceUnmountstill explicitly resetspreventUnmountingOnClose: false(line 286) — now potentially redundant. Either remove it with a comment explaining why, or keep it with a comment noting it covers the imperativeactionsRef.unmount()path.- JSDoc for
useOpenStateTransitions(lines 249-259) is now incomplete — it no longer mentions that the hook synchronizespreventUnmountingOnClose. - Console.error suppression filter is too lax: tighten to a single anchored regex and assert call count to catch additional unexpected warnings.
- Tighten
waitFors in the first tooltip test to direct sync assertions sinceBASE_UI_ANIMATIONS_DISABLEDmakes the transitions synchronous. - No test covers multiple prevented closes in a row (e.g., 3 prevented closes → unprevented close).
- No controlled-mode coverage of the remount path (both new tests use
defaultOpen).
Strengths
- The fix is correct and surgically localized to shared infrastructure — automatically benefits all 5 popup components.
useRef(false)initial value is the right choice (counter-intuitively).- Layout-effect ordering is correct; no concurrent-mode / Strict Mode hazards.
- Tests follow CLAUDE.md/AGENTS.md conventions (no
flushMicrotasksafterrender, Vitest-only matchers, run in jsdom). - The first tooltip test ("does not prevent unmounting on later closes") cleanly isolates the regression with no console suppression.
- Test names are descriptive and behavior-oriented; tests don't depend on internal implementation details.
- All affected test suites pass (607+ tests across 5 components).
Recommended Action Plan
- Critical: Address the console.error suppression — preferably fix
useOnFirstRendersetState-during-render, alternatively convert to an explicit assertion. - Critical: Move the second tooltip test out of the
describe.forparametrized block. - Important: Normalize to using the subscribed
preventUnmountingOnClosein both reads (lines 269 & 297). - Important: Add the three load-bearing WHY comments in
popupStoreUtils.ts(initial ref value, render-time computation, link tosetPopupOpenStateinvariant). - Consider: Add the prevented-close-and-remount regression test to Dialog/AlertDialog/PreviewCard suites.
- Re-run review after critical fixes.
Summary
preventUnmountOnClose()close from making later closes stay mounted forever.Bug
Calling
preventUnmountOnClose()latchespreventUnmountingOnClosepermanently. After one prevented close, later normal closes can keep the tooltip positioner mounted forever instead of unmounting after close completion.Reproduction
Test plan
pnpm test:jsdom TooltipRoot --no-watchpnpm test:chromium TooltipRoot --no-watch