diff --git a/.changeset/pink-tables-retire.md b/.changeset/pink-tables-retire.md
new file mode 100644
index 000000000..bc9091bc6
--- /dev/null
+++ b/.changeset/pink-tables-retire.md
@@ -0,0 +1,5 @@
+---
+"bits-ui": patch
+---
+
+fix(ContextMenu): rapid right click should not open multiple menus
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 a0e669472..f37ab07f5 100644
--- a/docs/src/routes/(docs)/sink/+page.svelte
+++ b/docs/src/routes/(docs)/sink/+page.svelte
@@ -1,5 +1,28 @@
-
+
+ {#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 48302a81b..7c77794d7 100644
--- a/packages/bits-ui/src/lib/app.d.ts
+++ b/packages/bits-ui/src/lib/app.d.ts
@@ -16,4 +16,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/menu/menu.svelte.ts b/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts
index ae86fb785..e13d17112 100644
--- a/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts
+++ b/packages/bits-ui/src/lib/bits/menu/menu.svelte.ts
@@ -1173,6 +1173,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) {
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 f697af71c..d8e735d36 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,43 +190,46 @@ 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;
+ }
- this.#unsubClickListener = on(this.#documentObj, "click", this.#handleDismiss, {
- once: true,
- });
- } else {
- this.#interactOutsideProp.current(event);
+ if (e.pointerType === "touch") {
+ this.#unsubClickListener();
+ this.#unsubClickListener = on(this.#documentObj, "click", this.#handleDismiss, {
+ once: true,
+ });
+ } else {
+ this.#interactOutsideProp.current(event);
+ }
+ } finally {
+ endInteractionWindow();
}
- }, 10);
+ };
#markInterceptedEvent = (e: PointerEvent) => {
this.#interceptedEvents[e.type] = true;