From feb6bc2cbf5124144ed57371ade4a0184ceefa5f Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 12 Sep 2025 11:46:43 -0400 Subject: [PATCH 1/3] fix(ContextMenu): rapid right click --- .changeset/pink-tables-retire.md | 5 +++++ packages/bits-ui/src/lib/bits/menu/menu.svelte.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/pink-tables-retire.md diff --git a/.changeset/pink-tables-retire.md b/.changeset/pink-tables-retire.md new file mode 100644 index 000000000..0a0cc6ac1 --- /dev/null +++ b/.changeset/pink-tables-retire.md @@ -0,0 +1,5 @@ +--- +"bits-ui": patch +--- + +fix(ContextMenu): rapid right click diff --git a/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts b/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts index ee3e96f9b..8fe1cf300 100644 --- a/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts +++ b/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts @@ -1161,6 +1161,7 @@ export class ContextMenuTriggerState { #clearLongPressTimer() { if (this.#longPressTimer === null) return; getWindow(this.opts.ref.current).clearTimeout(this.#longPressTimer); + this.#longPressTimer = null; } #handleOpen(e: BitsMouseEvent | BitsPointerEvent) { From f0453c4cf834d97e14a3e52a67100d274564505b Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Fri, 12 Sep 2025 13:16:35 -0400 Subject: [PATCH 2/3] fix --- docs/src/routes/(docs)/+layout.svelte | 16 +- docs/src/routes/(docs)/sink/+page.svelte | 85 +++-------- packages/bits-ui/src/lib/app.d.ts | 5 + .../use-dismissable-layer.svelte.ts | 142 ++++++++++++------ tests/src/tests/select/select.browser.test.ts | 10 +- 5 files changed, 137 insertions(+), 121 deletions(-) diff --git a/docs/src/routes/(docs)/+layout.svelte b/docs/src/routes/(docs)/+layout.svelte index 39ca4c5b3..e7e2bc28f 100644 --- a/docs/src/routes/(docs)/+layout.svelte +++ b/docs/src/routes/(docs)/+layout.svelte @@ -6,15 +6,15 @@ import SidebarNav from "$lib/components/navigation/sidebar-nav.svelte"; import { navigation } from "$lib/config/index.js"; import "$lib/styles/app.css"; - import { onMount } from "svelte"; - import { page } from "$app/state"; + // import { onMount } from "svelte"; + // import { page } from "$app/state"; - onMount(async () => { - if (dev || page.url.searchParams.get("test")) { - const eruda = (await import("eruda")).default; - eruda.init(); - } - }); + // onMount(async () => { + // if (dev || page.url.searchParams.get("test")) { + // const eruda = (await import("eruda")).default; + // eruda.init(); + // } + // }); let { children } = $props(); diff --git a/docs/src/routes/(docs)/sink/+page.svelte b/docs/src/routes/(docs)/sink/+page.svelte index 478df4990..f37ab07f5 100644 --- a/docs/src/routes/(docs)/sink/+page.svelte +++ b/docs/src/routes/(docs)/sink/+page.svelte @@ -1,65 +1,28 @@ -{#if bodyPointerEventsNone} -
- POINTER EVENTS NOT ALLOWED -
-{/if} - -
- +
+ {#each { length: 50 } as _, i (i)} + + +
+ Right click me {i} +
+
+ + + + {i} + + + +
+ {/each}
diff --git a/packages/bits-ui/src/lib/app.d.ts b/packages/bits-ui/src/lib/app.d.ts index 3ca975ccc..848a9d7c8 100644 --- a/packages/bits-ui/src/lib/app.d.ts +++ b/packages/bits-ui/src/lib/app.d.ts @@ -15,4 +15,9 @@ declare global { resetBodyStyle: () => void; }; var bitsAnimationsDisabled: boolean; + + // dismissible-layer interaction queue + var bitsDL_windowOpen: boolean; + var bitsDL_pendingLayerAdds: Array<() => void>; + var bitsDL_flushTimerId: number; } diff --git a/packages/bits-ui/src/lib/bits/utilities/dismissible-layer/use-dismissable-layer.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/dismissible-layer/use-dismissable-layer.svelte.ts index db7822ff7..cbe16bc38 100644 --- a/packages/bits-ui/src/lib/bits/utilities/dismissible-layer/use-dismissable-layer.svelte.ts +++ b/packages/bits-ui/src/lib/bits/utilities/dismissible-layer/use-dismissable-layer.svelte.ts @@ -1,7 +1,6 @@ import { type ReadableBox, type WritableBox, - afterSleep, afterTick, executeCallbacks, onDestroyEffect, @@ -22,6 +21,46 @@ globalThis.bitsDismissableLayers ??= new Map< ReadableBox >(); +// queue new layer registrations during an active outside-interaction window +// to avoid race conditions where a newly added layer becomes "responsible" +// before current layers finish handling the interaction +globalThis.bitsDL_windowOpen ??= false; +globalThis.bitsDL_pendingLayerAdds ??= []; +globalThis.bitsDL_flushTimerId ??= 0; + +function queueLayerRegistration(register: () => void) { + globalThis.bitsDL_pendingLayerAdds.push(register); +} + +function flushQueuedLayerAdds() { + const queue = globalThis.bitsDL_pendingLayerAdds; + while (queue.length) { + const fn = queue.shift()!; + try { + fn(); + } catch { + // ignore + } + } +} + +function beginInteractionWindow() { + globalThis.bitsDL_windowOpen = true; + if (globalThis.bitsDL_flushTimerId) window.clearTimeout(globalThis.bitsDL_flushTimerId); + // fallback to ensure the window eventually closes even if something prevents handlers + globalThis.bitsDL_flushTimerId = window.setTimeout(() => endInteractionWindow(), 120); +} + +function endInteractionWindow() { + if (!globalThis.bitsDL_windowOpen) return; + globalThis.bitsDL_windowOpen = false; + if (globalThis.bitsDL_flushTimerId) { + window.clearTimeout(globalThis.bitsDL_flushTimerId); + globalThis.bitsDL_flushTimerId = 0; + } + flushQueuedLayerAdds(); +} + interface DismissibleLayerStateOpts extends ReadableBoxedValues>> { ref: WritableBox; @@ -32,6 +71,7 @@ export class DismissibleLayerState { return new DismissibleLayerState(opts); } readonly opts: DismissibleLayerStateOpts; + #isDestroyed = false; #interactOutsideProp: ReadableBox>; #behaviorType: ReadableBox; #interceptedEvents: Record = { @@ -59,26 +99,33 @@ export class DismissibleLayerState { const cleanup = () => { this.#resetState(); globalThis.bitsDismissableLayers.delete(this); - this.#handleInteractOutside.destroy(); unsubEvents(); }; watch([() => this.opts.enabled.current, () => this.opts.ref.current], () => { if (!this.opts.enabled.current || !this.opts.ref.current) return; - afterSleep(1, () => { - if (!this.opts.ref.current) return; + const register = () => { + if (!this.opts.enabled.current || !this.opts.ref.current || this.#isDestroyed) + return; + // ensure document reference is up-to-date before attaching listeners + this.#documentObj = getOwnerDocument(this.opts.ref.current); globalThis.bitsDismissableLayers.set(this, this.#behaviorType); - unsubEvents(); unsubEvents = this.#addEventListeners(); - }); + }; + + if (globalThis.bitsDL_windowOpen) { + queueLayerRegistration(register); + } else { + register(); + } return cleanup; }); onDestroyEffect(() => { + this.#isDestroyed = true; this.#resetState.destroy(); globalThis.bitsDismissableLayers.delete(this); - this.#handleInteractOutside.destroy(); this.#unsubClickListener(); unsubEvents(); }); @@ -109,7 +156,11 @@ export class DismissibleLayerState { on( this.#documentObj, "pointerdown", - executeCallbacks(this.#markInterceptedEvent, this.#markResponsibleLayer), + executeCallbacks( + beginInteractionWindow, + this.#markInterceptedEvent, + this.#markResponsibleLayer + ), { capture: true } ), @@ -139,47 +190,50 @@ export class DismissibleLayerState { this.#interactOutsideProp.current(e as PointerEvent); }; - #handleInteractOutside = debounce((e: PointerEvent) => { - if (!this.opts.ref.current) { - this.#unsubClickListener(); - return; - } - const isEventValid = - this.opts.isValidEvent.current(e, this.opts.ref.current) || - isValidEvent(e, this.opts.ref.current); - - if (!this.#isResponsibleLayer || this.#isAnyEventIntercepted() || !isEventValid) { - this.#unsubClickListener(); - return; - } + #handleInteractOutside = (e: PointerEvent) => { + try { + if (!this.opts.ref.current) { + this.#unsubClickListener(); + return; + } + const isEventValid = + this.opts.isValidEvent.current(e, this.opts.ref.current) || + isValidEvent(e, this.opts.ref.current); - let event = e; - if (event.defaultPrevented) { - event = createWrappedEvent(event); - } + if (!this.#isResponsibleLayer || this.#isAnyEventIntercepted() || !isEventValid) { + this.#unsubClickListener(); + return; + } - if ( - this.#behaviorType.current !== "close" && - this.#behaviorType.current !== "defer-otherwise-close" - ) { - this.#unsubClickListener(); - return; - } + let event = e; + if (event.defaultPrevented) { + event = createWrappedEvent(event); + } - if (e.pointerType === "touch") { - this.#unsubClickListener(); + if ( + this.#behaviorType.current !== "close" && + this.#behaviorType.current !== "defer-otherwise-close" + ) { + this.#unsubClickListener(); + return; + } - // @ts-expect-error - later - this.#unsubClickListener = addEventListener( - this.#documentObj, - "click", - this.#handleDismiss, - { once: true } - ); - } else { - this.#interactOutsideProp.current(event); + if (e.pointerType === "touch") { + this.#unsubClickListener(); + // @ts-expect-error - later + this.#unsubClickListener = addEventListener( + this.#documentObj, + "click", + this.#handleDismiss, + { once: true } + ); + } else { + this.#interactOutsideProp.current(event); + } + } finally { + endInteractionWindow(); } - }, 10); + }; #markInterceptedEvent = (e: PointerEvent) => { this.#interceptedEvents[e.type] = true; diff --git a/tests/src/tests/select/select.browser.test.ts b/tests/src/tests/select/select.browser.test.ts index 4218df554..4fbf445b2 100644 --- a/tests/src/tests/select/select.browser.test.ts +++ b/tests/src/tests/select/select.browser.test.ts @@ -246,11 +246,8 @@ describe("select - single", () => { }); it("should respect binding the `open` prop", async () => { - const t = await openSingle(); - expect(t.openBinding).toHaveTextContent("true"); - await t.user.click(t.openBinding); + const t = setupSingle(); expect(t.openBinding).toHaveTextContent("false"); - await vi.waitFor(() => expectNotExists(t.getContent())); await t.user.click(t.openBinding); expect(t.openBinding).toHaveTextContent("true"); await vi.waitFor(() => expectExists(t.getContent())); @@ -664,11 +661,8 @@ describe("select - multiple", () => { }); it("should respect binding the `open` prop", async () => { - const t = await openMultiple(); - expect(t.openBinding).toHaveTextContent("true"); - await t.user.click(t.openBinding); + const t = setupMultiple(); expect(t.openBinding).toHaveTextContent("false"); - await vi.waitFor(() => expectNotExists(t.getContent())); await t.user.click(t.openBinding); expect(t.openBinding).toHaveTextContent("true"); await vi.waitFor(() => expectExists(t.getContent())); From 247423bb5a9b05f7f86f033fa927167986457c37 Mon Sep 17 00:00:00 2001 From: Hunter Johnston Date: Tue, 23 Sep 2025 15:55:31 -0400 Subject: [PATCH 3/3] cs --- .changeset/pink-tables-retire.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/pink-tables-retire.md b/.changeset/pink-tables-retire.md index 0a0cc6ac1..bc9091bc6 100644 --- a/.changeset/pink-tables-retire.md +++ b/.changeset/pink-tables-retire.md @@ -2,4 +2,4 @@ "bits-ui": patch --- -fix(ContextMenu): rapid right click +fix(ContextMenu): rapid right click should not open multiple menus