Skip to content

fix(DismissibleLayer): make #resetState synchronous - #2111

Open
MathiasWP wants to merge 1 commit into
huntabyte:mainfrom
MathiasWP:flake/combobox-outside-click-repro
Open

fix(DismissibleLayer): make #resetState synchronous#2111
MathiasWP wants to merge 1 commit into
huntabyte:mainfrom
MathiasWP:flake/combobox-outside-click-repro

Conversation

@MathiasWP

Copy link
Copy Markdown
Contributor

Fixes the intermittent should close on outside click failures in combobox/select/menubar.

Problem

#resetState clears #isResponsibleLayer behind a debounce(..., 20), and is only called from cleanup(). Since watch([enabled, ref]) runs cleanup() once on every open (ref.current goes null -> node), every layer schedules a state wipe 20ms into its own lifetime, ~1.5ms before it attaches its listeners.

An outside pointerdown schedules #handleInteractOutside on a 10ms debounce, so a click landing 10-20ms after that cleanup gets the stale reset in between:

t+0.0   cleanup()             -> schedules resetState at t+20
t+1.5   attach listeners
t+10.5  pointerdown capture   -> isResponsibleLayer = true
t+10.7  pointerdown bubble    -> schedules handleInteractOutside at t+20.7
t+20.5  resetState (stale)    -> isResponsibleLayer = false
t+20.7  handleInteractOutside -> responsible=false -> bail, layer stays open

The debounce is vestigial: #785 removed the capture-phase interaction-end listener it was wired to (where landing after the 10ms handler debounce was the point) but left the timing on the cleanup() path.

Fix

Make #resetState synchronous. cleanup() destroys #handleInteractOutside in the same breath, so nothing in flight needs the pre-reset state.

Test

tests/src/tests/dismissible-layer/outside-click-timing.browser.test.ts sweeps the outside-click delay after layer registration. Fails 4-5 of 20 without the fix, passes 20/20 with it.

Other verification, chromium --retry=0:

before after
delay sweep (11 delays x 10 reps) 22/110 failed, band 4-14ms 0/110
unguided repro, 300 rounds x combobox single/multi + select single 2/900 failed 0/1500; 0/900 under 16x CPU load
10 interaction-heavy files x 10 runs 12 outside-click failures 0
full browser suite x 3 3/3 clean

The 20ms debounce on #resetState is vestigial: it dates from when the reset
was also wired to a capture-phase interaction-end listener and had to land
after the 10ms #handleInteractOutside debounce. huntabyte#785 removed that listener
but left the timing on the cleanup() path.

Because watch([enabled, ref]) runs cleanup() once on every open (ref goes
null -> node), every layer scheduled a reset 20ms into its own lifetime. An
outside pointerdown landing 10-20ms after that cleanup had its
#isResponsibleLayer flag cleared by the stale reset in the gap before the
debounced #handleInteractOutside ran, so the handler bailed and the layer
stayed open. This is the 'should close on outside click' CI flake.

Adds a delay-sweep regression test that clicks outside at a range of offsets
after the layer registers; it fails 4-5 of 20 without the fix and passes
20/20 with it.
@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: eb47caf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
bits-ui Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor
built with Refined Cloudflare Pages Action

⚡ Cloudflare Pages Deployment

Name Status Preview Last Commit
bits-ui ✅ Ready (View Log) Visit Preview eb47caf

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.

1 participant