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
11 changes: 11 additions & 0 deletions .changeset/quiet-parrots-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"bits-ui": patch
---

fix(DismissibleLayer): outside clicks shortly after a layer opens no longer fail to dismiss it

`DismissibleLayerState` reset its per-interaction state through a 20ms debounce. Because the
layer's `watch` runs its cleanup once on every open, each layer scheduled a reset 20ms into its
own lifetime. An outside `pointerdown` landing 10-20ms after that cleanup had its
"responsible layer" flag cleared by the stale reset before the debounced interact-outside
handler ran, so the handler bailed and the layer stayed open. The reset is now synchronous.
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export class DismissibleLayerState {
onDestroyEffect(() => {
destroyed = true;
clearPendingTimer();
this.#resetState.destroy();
this.#resetState();
globalThis.bitsDismissableLayers.delete(this);
this.#handleInteractOutside.destroy();
this.#unsubClickListener();
Expand Down Expand Up @@ -217,12 +217,25 @@ export class DismissibleLayerState {
return isOrContainsTarget(this.opts.ref.current, target);
};

#resetState = debounce(() => {
/**
* Resets the per-interaction state. Must stay synchronous.
*
* This was a `debounce(..., 20)` from when it was also wired to a capture-phase
* interaction-end listener and had to land after the 10ms `#handleInteractOutside`
* debounce. That listener is gone, but the debounce stayed on the `cleanup()` path —
* and because `watch` runs `cleanup()` once per open (`ref` goes null -> node), every
* layer scheduled a reset 20ms into its own lifetime. An outside `pointerdown` landing
* 10-20ms after that cleanup would have its `#isResponsibleLayer` flag cleared by the
* stale reset in the gap before the debounced `#handleInteractOutside` ran, which then
* bailed and left the layer open. `cleanup()` destroys `#handleInteractOutside` anyway,
* so nothing is left in flight that needs to observe the pre-reset state.
*/
#resetState = () => {
for (const eventType in this.#interceptedEvents) {
this.#interceptedEvents[eventType] = false;
}
this.#isResponsibleLayer = false;
}, 20);
};

#isAnyEventIntercepted() {
const i = Object.values(this.#interceptedEvents).some(Boolean);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { page } from "@vitest/browser/context";
import { describe, it } from "vitest";
import { render } from "vitest-browser-svelte";
import ComboboxTest, { type Item } from "../combobox/combobox-test.svelte";
import { expectExists, expectNotExists, waitForDismissibleLayer } from "../browser-utils";

/**
* An outside click must dismiss the layer no matter how soon after the layer registered
* it happens.
*
* Regression guard for a stale timer scheduled by `DismissibleLayerState`'s watch cleanup:
* it fired ~20ms into the layer's own lifetime and cleared the `isResponsibleLayer` flag
* that the outside `pointerdown` had just set, in the window before the debounced
* interact-outside handler ran. Clicks landing roughly 4-14ms after registration hit that
* window, so the delay sweep below is what makes the bug reproducible instead of a ~1-in-100
* CI flake.
*/

const items: Item[] = [
{ value: "1", label: "A" },
{ value: "2", label: "B" },
{ value: "3", label: "C" },
{ value: "4", label: "D" },
];

const DELAYS_MS = [0, 2, 4, 6, 8, 10, 12, 14, 16, 20];
const REPS = 2;

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

describe("dismissible layer - outside click timing", () => {
for (const delay of DELAYS_MS) {
for (let rep = 0; rep < REPS; rep++) {
it(`should close on an outside click ${delay}ms after the layer registers (rep ${rep})`, async () => {
render(ComboboxTest, { name: "test", items });

await page.getByTestId("trigger").click({ force: true });
await expectExists(page.getByTestId("content"));
await waitForDismissibleLayer(page.getByTestId("content"));

await sleep(delay);

await page.getByTestId("outside").click({ force: true });
await expectNotExists(page.getByTestId("content"));
});
}
}
});