bits-ui 2.9.1 → 2.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@
6
6
  import PopperLayer from "../../utilities/popper-layer/popper-layer.svelte";
7
7
  import { getFloatingContentCSSVars } from "../../../internal/floating-svelte/floating-utils.svelte.js";
8
8
  import PopperLayerForceMount from "../../utilities/popper-layer/popper-layer-force-mount.svelte";
9
+ import Mounted from "../../utilities/mounted.svelte";
9
10
  import { noop } from "../../../internal/noop.js";
10
11
 
11
12
  const uid = $props.id();
@@ -58,6 +59,7 @@
58
59
  {@render children?.()}
59
60
  </div>
60
61
  {/if}
62
+ <Mounted bind:mounted={contentState.root.contentMounted} />
61
63
  {/snippet}
62
64
  </PopperLayerForceMount>
63
65
  {:else if !forceMount}
@@ -79,6 +79,7 @@
79
79
  </div>
80
80
  </div>
81
81
  {/if}
82
+ <Mounted bind:mounted={contentState.root.contentMounted} />
82
83
  {/snippet}
83
84
  </PopperLayerForceMount>
84
85
  {:else if !forceMount}
@@ -126,7 +126,7 @@ export class LinkPreviewTriggerState {
126
126
  onpointerleave(e) {
127
127
  if (isTouch(e))
128
128
  return;
129
- if (!this.root.contentMounted) {
129
+ if (!this.root.contentMounted || !this.root.opts.open.current) {
130
130
  this.root.immediateClose();
131
131
  }
132
132
  }
@@ -1,32 +1,94 @@
1
1
  import { SvelteMap } from "svelte/reactivity";
2
- import { afterSleep, afterTick, box, onDestroyEffect, } from "svelte-toolbelt";
3
- import { isBrowser, isIOS } from "./is.js";
2
+ import { afterTick, box, onDestroyEffect } from "svelte-toolbelt";
3
+ import { isIOS } from "./is.js";
4
4
  import { addEventListener } from "./events.js";
5
5
  import { useId } from "./use-id.js";
6
6
  import { watch } from "runed";
7
7
  import { SharedState } from "./shared-state.svelte.js";
8
+ import { BROWSER } from "esm-env";
9
+ /** A map of lock ids to their `locked` state. */
10
+ const lockMap = new SvelteMap();
11
+ let initialBodyStyle = $state(null);
12
+ let stopTouchMoveListener = null;
13
+ let cleanupTimeoutId = null;
14
+ const anyLocked = box.with(() => {
15
+ for (const value of lockMap.values()) {
16
+ if (value)
17
+ return true;
18
+ }
19
+ return false;
20
+ });
21
+ /**
22
+ * We track the time we scheduled the cleanup to prevent race conditions
23
+ * when multiple locks are created/destroyed in the same tick, ensuring
24
+ * only the last one to schedule the cleanup will run.
25
+ *
26
+ * reference: https://github.com/huntabyte/bits-ui/issues/1639
27
+ */
28
+ let cleanupScheduledAt = null;
8
29
  const bodyLockStackCount = new SharedState(() => {
9
- const map = new SvelteMap();
10
- const locked = $derived.by(() => {
11
- for (const value of map.values()) {
12
- if (value)
13
- return true;
14
- }
15
- return false;
16
- });
17
- let initialBodyStyle = $state(null);
18
- let stopTouchMoveListener = null;
19
30
  function resetBodyStyle() {
20
- if (!isBrowser)
31
+ if (!BROWSER)
21
32
  return;
22
33
  document.body.setAttribute("style", initialBodyStyle ?? "");
23
34
  document.body.style.removeProperty("--scrollbar-width");
24
35
  isIOS && stopTouchMoveListener?.();
36
+ // reset initialBodyStyle so next locker captures the correct styles
37
+ initialBodyStyle = null;
38
+ hasEverBeenLocked = false;
25
39
  }
26
- watch(() => locked, () => {
27
- if (!locked)
40
+ function cancelPendingCleanup() {
41
+ if (cleanupTimeoutId === null)
28
42
  return;
29
- initialBodyStyle = document.body.getAttribute("style");
43
+ window.clearTimeout(cleanupTimeoutId);
44
+ cleanupTimeoutId = null;
45
+ }
46
+ function scheduleCleanupIfNoNewLocks(delay, callback) {
47
+ cancelPendingCleanup();
48
+ cleanupScheduledAt = Date.now();
49
+ const currentCleanupId = cleanupScheduledAt;
50
+ /**
51
+ * We schedule the cleanup to run after a delay to allow new locks to register
52
+ * that might have been added in the same tick as the current cleanup.
53
+ *
54
+ * If a new lock is added in the same tick, the cleanup will be cancelled and
55
+ * a new cleanup will be scheduled.
56
+ *
57
+ * This is to prevent the cleanup from running too early and resetting the body
58
+ * style before the new lock has had a chance to apply its styles.
59
+ */
60
+ const cleanupFn = () => {
61
+ cleanupTimeoutId = null;
62
+ // check if this cleanup is still valid (no newer cleanups scheduled)
63
+ if (cleanupScheduledAt !== currentCleanupId)
64
+ return;
65
+ // ensure no new locks were added during the delay
66
+ if (!isAnyLocked(lockMap)) {
67
+ callback();
68
+ }
69
+ };
70
+ if (delay === null) {
71
+ // use a small delay even when no restoreScrollDelay is set
72
+ // to handle same-tick destroy/create scenarios (~1 frame)
73
+ cleanupTimeoutId = window.setTimeout(cleanupFn, 16);
74
+ }
75
+ else {
76
+ cleanupTimeoutId = window.setTimeout(cleanupFn, delay);
77
+ }
78
+ }
79
+ // track if we've ever applied lock styles in this session
80
+ let hasEverBeenLocked = false;
81
+ function ensureInitialStyleCaptured() {
82
+ if (!hasEverBeenLocked && initialBodyStyle === null) {
83
+ initialBodyStyle = document.body.getAttribute("style");
84
+ hasEverBeenLocked = true;
85
+ }
86
+ }
87
+ watch(() => anyLocked.current, () => {
88
+ if (!anyLocked.current)
89
+ return;
90
+ // ensure we've captured the initial style before applying any lock styles
91
+ ensureInitialStyleCaptured();
30
92
  const bodyStyle = getComputedStyle(document.body);
31
93
  // TODO: account for RTL direction, etc.
32
94
  const verticalScrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
@@ -42,6 +104,7 @@ const bodyLockStackCount = new SharedState(() => {
42
104
  document.body.style.overflow = "hidden";
43
105
  }
44
106
  if (isIOS) {
107
+ // IOS devices are special and require a touchmove listener to prevent scrolling
45
108
  stopTouchMoveListener = addEventListener(document, "touchmove", (e) => {
46
109
  if (e.target !== document.documentElement)
47
110
  return;
@@ -50,6 +113,14 @@ const bodyLockStackCount = new SharedState(() => {
50
113
  e.preventDefault();
51
114
  }, { passive: false });
52
115
  }
116
+ /**
117
+ * We ensure pointer-events: none is applied _after_ DOM updates, so that any focus/
118
+ * interaction changes from opening overlays/menus complete _before_ we block pointer
119
+ * events.
120
+ *
121
+ * this avoids race conditions where pointer-events could be set too early and break
122
+ * focus/interaction.
123
+ */
53
124
  afterTick(() => {
54
125
  document.body.style.pointerEvents = "none";
55
126
  document.body.style.overflow = "hidden";
@@ -61,10 +132,13 @@ const bodyLockStackCount = new SharedState(() => {
61
132
  };
62
133
  });
63
134
  return {
64
- get map() {
65
- return map;
135
+ get lockMap() {
136
+ return lockMap;
66
137
  },
67
138
  resetBodyStyle,
139
+ scheduleCleanupIfNoNewLocks,
140
+ cancelPendingCleanup,
141
+ ensureInitialStyleCaptured,
68
142
  };
69
143
  });
70
144
  export class BodyScrollLock {
@@ -79,21 +153,33 @@ export class BodyScrollLock {
79
153
  this.#countState = bodyLockStackCount.get();
80
154
  if (!this.#countState)
81
155
  return;
82
- this.#countState.map.set(this.#id, this.#initialState ?? false);
83
- this.locked = box.with(() => this.#countState.map.get(this.#id) ?? false, (v) => this.#countState.map.set(this.#id, v));
156
+ /**
157
+ * Since a new lock is being created, we cancel any pending cleanup to
158
+ * prevent the cleanup from running too early and resetting the body style
159
+ * before the new lock has had a chance to apply its styles.
160
+ *
161
+ * reference: https://github.com/huntabyte/bits-ui/issues/1639
162
+ */
163
+ this.#countState.cancelPendingCleanup();
164
+ // capture initial style before this lock is registered
165
+ this.#countState.ensureInitialStyleCaptured();
166
+ this.#countState.lockMap.set(this.#id, this.#initialState ?? false);
167
+ this.locked = box.with(() => this.#countState.lockMap.get(this.#id) ?? false, (v) => this.#countState.lockMap.set(this.#id, v));
84
168
  onDestroyEffect(() => {
85
- this.#countState.map.delete(this.#id);
86
- // if any locks are still active, we don't reset the body style
87
- if (isAnyLocked(this.#countState.map))
169
+ this.#countState.lockMap.delete(this.#id);
170
+ // if not the last lock, we don't need to do anything
171
+ if (isAnyLocked(this.#countState.lockMap))
88
172
  return;
89
173
  const restoreScrollDelay = this.#restoreScrollDelay();
90
- // if no locks are active (meaning this was the last lock), we reset the body style
91
- if (restoreScrollDelay === null) {
92
- requestAnimationFrame(() => this.#countState.resetBodyStyle());
93
- }
94
- else {
95
- afterSleep(restoreScrollDelay, () => this.#countState.resetBodyStyle());
96
- }
174
+ /**
175
+ * We schedule the cleanup to run after a delay to handle same-tick
176
+ * destroy/create scenarios.
177
+ *
178
+ * reference: https://github.com/huntabyte/bits-ui/issues/1639
179
+ */
180
+ this.#countState.scheduleCleanupIfNoNewLocks(restoreScrollDelay, () => {
181
+ this.#countState.resetBodyStyle();
182
+ });
97
183
  });
98
184
  }
99
185
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bits-ui",
3
- "version": "2.9.1",
3
+ "version": "2.9.3",
4
4
  "license": "MIT",
5
5
  "repository": "github:huntabyte/bits-ui",
6
6
  "funding": "https://github.com/sponsors/huntabyte",