modal_stack 0.1.1 → 0.3.0

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.
@@ -9,12 +9,50 @@ import {
9
9
  snapshot,
10
10
  } from "./state.js";
11
11
 
12
+ // How long a successful prefetch is reused before being refetched. Short
13
+ // enough that stale server-rendered HTML doesn't linger; long enough to
14
+ // absorb back/forward bounces and rapid double-clicks.
15
+ const PREFETCH_TTL_MS = 30_000;
16
+
17
+ /**
18
+ * Owns the current `Stack`, calls the pure reducer, and executes the emitted
19
+ * commands against an injected runtime. The only stateful piece is
20
+ * `#expectedPopstates`, which lets us avoid re-entering the reducer when our
21
+ * own `historyBack` calls fire `popstate`.
22
+ *
23
+ * @typedef {Object} OrchestratorOptions
24
+ * @property {object} runtime Instance with one method per command type
25
+ * @property {string} stackId
26
+ * @property {string} baseUrl
27
+ * @property {string|null} [restoreFrom] Serialized snapshot from sessionStorage
28
+ * @property {number|null} [maxDepth] null disables the cap
29
+ * @property {"raise"|"warn"|"silent"} [maxDepthStrategy]
30
+ * @property {number} [prefetchTtlMs] Override the prefetch cache TTL (testing)
31
+ */
12
32
  export class Orchestrator {
13
33
  #expectedPopstates = 0;
34
+ // url → { fragment, ts }. Fragment is the canonical copy; consumers
35
+ // always receive a `cloneNode(true)` so the cached entry stays intact.
36
+ #fragmentCache = new Map();
37
+ // url → { controller, promise }. Lets concurrent prefetches dedupe onto
38
+ // the same in-flight request, and gives `closeAll` a way to cancel them.
39
+ #inflight = new Map();
14
40
 
15
- constructor({ runtime, stackId, baseUrl, restoreFrom = null }) {
41
+ /** @param {OrchestratorOptions} options */
42
+ constructor({
43
+ runtime,
44
+ stackId,
45
+ baseUrl,
46
+ restoreFrom = null,
47
+ maxDepth = null,
48
+ maxDepthStrategy = "warn",
49
+ prefetchTtlMs = PREFETCH_TTL_MS,
50
+ }) {
16
51
  if (!runtime) throw new Error("runtime required");
17
52
  this.runtime = runtime;
53
+ this.maxDepth = maxDepth;
54
+ this.maxDepthStrategy = maxDepthStrategy;
55
+ this.prefetchTtlMs = prefetchTtlMs;
18
56
  this.state = createStack({ stackId, baseUrl });
19
57
 
20
58
  if (restoreFrom) {
@@ -31,17 +69,34 @@ export class Orchestrator {
31
69
  return this.state.layers.length;
32
70
  }
33
71
 
72
+ /**
73
+ * Push a layer. When `html`/`fragment` are absent, the orchestrator
74
+ * pre-fetches the URL so `mountLayer` is a sync DOM append (no flash).
75
+ * @param {Partial<import("./state.js").Layer> & { id: string, url: string }} layer
76
+ * @param {{ html?: string|null, fragment?: DocumentFragment|null }} [options]
77
+ */
34
78
  async push(layer, { html = null, fragment = null } = {}) {
79
+ const transition = push(this.state, layer, {
80
+ maxDepth: this.maxDepth,
81
+ maxDepthStrategy: this.maxDepthStrategy,
82
+ });
83
+ if (transition.commands.length === 0) return;
84
+
35
85
  if (fragment == null && html == null && layer?.url) {
36
86
  fragment = await this.#prefetch(layer.url);
37
87
  }
38
- return this.#dispatch(push(this.state, layer), { html, fragment });
88
+ return this.#dispatch(transition, { html, fragment });
39
89
  }
40
90
 
41
91
  pop() {
42
92
  return this.#dispatch(pop(this.state));
43
93
  }
44
94
 
95
+ /**
96
+ * Replace (morph) the top layer.
97
+ * @param {Partial<import("./state.js").Layer>} patch
98
+ * @param {{ html?: string|null, fragment?: DocumentFragment|null, historyMode?: "push"|"replace" }} [options]
99
+ */
45
100
  async replaceTop(patch, { html = null, fragment = null, ...opts } = {}) {
46
101
  if (fragment == null && html == null && patch?.url) {
47
102
  fragment = await this.#prefetch(patch.url);
@@ -51,10 +106,64 @@ export class Orchestrator {
51
106
 
52
107
  async #prefetch(url) {
53
108
  if (typeof this.runtime.fetchFragment !== "function") return null;
54
- return this.runtime.fetchFragment(url);
109
+
110
+ const cached = this.#fragmentCache.get(url);
111
+ if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
112
+ return cloneFragment(cached.fragment);
113
+ }
114
+
115
+ const existing = this.#inflight.get(url);
116
+ if (existing) {
117
+ const entry = await existing.promise;
118
+ return cloneFragment(entry.fragment);
119
+ }
120
+
121
+ const controller = supportsAbort() ? new AbortController() : null;
122
+ const fetchPromise = this.runtime
123
+ .fetchFragment(url, controller ? { signal: controller.signal } : undefined)
124
+ .then((fragment) => {
125
+ const entry = { fragment, ts: Date.now() };
126
+ this.#fragmentCache.set(url, entry);
127
+ return entry;
128
+ })
129
+ .finally(() => {
130
+ this.#inflight.delete(url);
131
+ });
132
+
133
+ this.#inflight.set(url, { controller, promise: fetchPromise });
134
+ const entry = await fetchPromise;
135
+ return cloneFragment(entry.fragment);
136
+ }
137
+
138
+ // Aborts every in-flight prefetch and forgets any cached fragments.
139
+ // Called when we tear the stack down (closeAll / cross-stack popstate)
140
+ // because the URLs in flight are no longer relevant. In-flight callers
141
+ // see an AbortError; caller code (controllers) already wraps push/pop
142
+ // in try/catch via `guarded()`.
143
+ #invalidatePrefetch() {
144
+ for (const { controller } of this.#inflight.values()) {
145
+ try {
146
+ controller?.abort();
147
+ } catch {
148
+ // ignore — abort is best-effort
149
+ }
150
+ }
151
+ this.#inflight.clear();
152
+ this.#fragmentCache.clear();
153
+ }
154
+
155
+ // Warm the prefetch cache for `url` without mutating the stack. Safe
156
+ // to call repeatedly for the same URL (deduped via #inflight) and from
157
+ // hover/focus handlers; failures are swallowed since this is best-effort.
158
+ prefetch(url) {
159
+ if (!url || typeof this.runtime.fetchFragment !== "function") {
160
+ return Promise.resolve(null);
161
+ }
162
+ return this.#prefetch(url).catch(() => null);
55
163
  }
56
164
 
57
165
  closeAll() {
166
+ this.#invalidatePrefetch();
58
167
  return this.#dispatch(closeAll(this.state));
59
168
  }
60
169
 
@@ -63,6 +172,9 @@ export class Orchestrator {
63
172
  this.#expectedPopstates -= 1;
64
173
  return Promise.resolve();
65
174
  }
175
+ // A popstate arriving while we have prefetches in flight means the
176
+ // user navigated away from any URL we were preloading; drop them.
177
+ this.#invalidatePrefetch();
66
178
  return this.#dispatch(
67
179
  handlePopstate(this.state, { historyState, locationHref }),
68
180
  );
@@ -91,8 +203,28 @@ export class Orchestrator {
91
203
 
92
204
  const handler = this.runtime[cmd.type];
93
205
  if (typeof handler !== "function") {
94
- throw new Error(`runtime missing handler for "${cmd.type}"`);
206
+ const known = Object.getOwnPropertyNames(Object.getPrototypeOf(this.runtime))
207
+ .filter((name) => name !== "constructor" && typeof this.runtime[name] === "function")
208
+ .sort()
209
+ .join(", ");
210
+ throw new Error(
211
+ `[modal_stack] runtime missing handler for "${cmd.type}" ` +
212
+ `(stack depth=${this.depth}). ` +
213
+ `Known handlers: ${known || "<none>"}.`,
214
+ );
95
215
  }
96
216
  await handler.call(this.runtime, cmd);
97
217
  }
98
218
  }
219
+
220
+ function cloneFragment(fragment) {
221
+ if (!fragment) return fragment;
222
+ if (typeof fragment.cloneNode === "function") {
223
+ return fragment.cloneNode(true);
224
+ }
225
+ return fragment;
226
+ }
227
+
228
+ function supportsAbort() {
229
+ return typeof globalThis.AbortController === "function";
230
+ }
@@ -208,8 +208,8 @@ describe("closeAll", () => {
208
208
  expect(orchestrator.depth).toBe(0);
209
209
  const types = runtime._calls.map((c) => c.type);
210
210
  expect(types).toEqual([
211
- "unmountAllLayers",
212
211
  "closeDialog",
212
+ "unmountAllLayers",
213
213
  "unlockScroll",
214
214
  "historyBack",
215
215
  "clearSnapshot",
@@ -224,6 +224,172 @@ describe("closeAll", () => {
224
224
  });
225
225
  });
226
226
 
227
+ describe("prefetch cache + abort", () => {
228
+ // Each fakeFragment supports cloneNode so the orchestrator can hand out
229
+ // independent copies without exhausting the cached entry.
230
+ function fakeFragment(label) {
231
+ return {
232
+ label,
233
+ consumed: false,
234
+ cloneNode() {
235
+ return fakeFragment(label);
236
+ },
237
+ };
238
+ }
239
+
240
+ function fetchingRuntime({ delayMs = 0, fail = false } = {}) {
241
+ const calls = [];
242
+ const aborts = [];
243
+ const handlerNames = [
244
+ "showDialog",
245
+ "lockScroll",
246
+ "inertLayer",
247
+ "mountLayer",
248
+ "morphTopLayer",
249
+ "unmountTopLayer",
250
+ "unmountAllLayers",
251
+ "closeDialog",
252
+ "unlockScroll",
253
+ "pushHistory",
254
+ "replaceHistory",
255
+ "historyBack",
256
+ "rebuildFromSnapshot",
257
+ "persistSnapshot",
258
+ "clearSnapshot",
259
+ ];
260
+ const runtime = { _calls: calls, _fetches: [], _aborts: aborts };
261
+ for (const name of handlerNames) {
262
+ runtime[name] = (cmd) => {
263
+ calls.push({ type: name, ...cmd });
264
+ };
265
+ }
266
+ runtime.fetchFragment = (url, { signal } = {}) => {
267
+ runtime._fetches.push(url);
268
+ return new Promise((resolve, reject) => {
269
+ const t = setTimeout(() => {
270
+ if (fail) reject(new Error("boom"));
271
+ else resolve(fakeFragment(`frag:${url}`));
272
+ }, delayMs);
273
+ if (signal) {
274
+ signal.addEventListener("abort", () => {
275
+ clearTimeout(t);
276
+ aborts.push(url);
277
+ const err = new Error("aborted");
278
+ err.name = "AbortError";
279
+ reject(err);
280
+ });
281
+ }
282
+ });
283
+ };
284
+ return runtime;
285
+ }
286
+
287
+ test("dedupes concurrent prefetches for the same url", async () => {
288
+ const rt = fetchingRuntime({ delayMs: 5 });
289
+ const orch = new Orchestrator({
290
+ runtime: rt,
291
+ stackId: STACK_ID,
292
+ baseUrl: BASE_URL,
293
+ });
294
+ await Promise.all([
295
+ orch.push({ id: "L1", url: "/x" }),
296
+ orch.push({ id: "L2", url: "/x" }),
297
+ ]);
298
+ expect(rt._fetches).toEqual(["/x"]);
299
+ });
300
+
301
+ test("hits the cache on a second push to the same url", async () => {
302
+ const rt = fetchingRuntime();
303
+ const orch = new Orchestrator({
304
+ runtime: rt,
305
+ stackId: STACK_ID,
306
+ baseUrl: BASE_URL,
307
+ });
308
+ await orch.push({ id: "L1", url: "/x" });
309
+ await orch.pop();
310
+ await orch.push({ id: "L2", url: "/x" });
311
+ expect(rt._fetches).toEqual(["/x"]);
312
+ });
313
+
314
+ test("returns a fresh clone per consumer (cache survives consumption)", async () => {
315
+ const rt = fetchingRuntime();
316
+ const orch = new Orchestrator({
317
+ runtime: rt,
318
+ stackId: STACK_ID,
319
+ baseUrl: BASE_URL,
320
+ });
321
+ const seenFragments = [];
322
+ rt.mountLayer = (cmd) => {
323
+ seenFragments.push(cmd.fragment);
324
+ };
325
+ await orch.push({ id: "L1", url: "/x" });
326
+ await orch.pop();
327
+ await orch.push({ id: "L2", url: "/x" });
328
+ expect(seenFragments).toHaveLength(2);
329
+ expect(seenFragments[0]).not.toBe(seenFragments[1]);
330
+ });
331
+
332
+ test("TTL expires the cache and triggers a refetch", async () => {
333
+ const rt = fetchingRuntime();
334
+ const orch = new Orchestrator({
335
+ runtime: rt,
336
+ stackId: STACK_ID,
337
+ baseUrl: BASE_URL,
338
+ prefetchTtlMs: 1,
339
+ });
340
+ await orch.push({ id: "L1", url: "/x" });
341
+ await orch.pop();
342
+ await new Promise((r) => setTimeout(r, 5));
343
+ await orch.push({ id: "L2", url: "/x" });
344
+ expect(rt._fetches).toEqual(["/x", "/x"]);
345
+ });
346
+
347
+ test("prefetch warms the cache without dispatching commands", async () => {
348
+ const rt = fetchingRuntime();
349
+ const orch = new Orchestrator({
350
+ runtime: rt,
351
+ stackId: STACK_ID,
352
+ baseUrl: BASE_URL,
353
+ });
354
+ await orch.prefetch("/x");
355
+ expect(rt._fetches).toEqual(["/x"]);
356
+ expect(rt._calls).toEqual([]);
357
+ // Subsequent push consumes the cache, no second fetch.
358
+ await orch.push({ id: "L1", url: "/x" });
359
+ expect(rt._fetches).toEqual(["/x"]);
360
+ });
361
+
362
+ test("prefetch swallows errors (best-effort)", async () => {
363
+ const rt = fetchingRuntime({ fail: true });
364
+ const orch = new Orchestrator({
365
+ runtime: rt,
366
+ stackId: STACK_ID,
367
+ baseUrl: BASE_URL,
368
+ });
369
+ await expect(orch.prefetch("/boom")).resolves.toBeNull();
370
+ });
371
+
372
+ test("closeAll aborts in-flight prefetches and clears the cache", async () => {
373
+ const rt = fetchingRuntime({ delayMs: 50 });
374
+ const orch = new Orchestrator({
375
+ runtime: rt,
376
+ stackId: STACK_ID,
377
+ baseUrl: BASE_URL,
378
+ });
379
+ // First push completes so the cache has /x.
380
+ await orch.push({ id: "L1", url: "/x" });
381
+ // Second push starts fetching /y and stays in flight.
382
+ const inflight = orch.push({ id: "L2", url: "/y" });
383
+ await new Promise((r) => setTimeout(r, 5));
384
+ await orch.closeAll();
385
+ await expect(inflight).rejects.toThrow(/aborted/);
386
+ expect(rt._aborts).toContain("/y");
387
+ // Cache has been cleared too: re-push of /x must refetch.
388
+ await orch.push({ id: "L3", url: "/x" });
389
+ expect(rt._fetches.filter((u) => u === "/x")).toHaveLength(2);
390
+ });
391
+ });
392
+
227
393
  describe("onPopstate", () => {
228
394
  test("forward navigation requests rebuild from snapshot", async () => {
229
395
  await orchestrator.push({ id: "L1", url: "/x" });
@@ -238,6 +404,56 @@ describe("onPopstate", () => {
238
404
  expect(rebuild).toMatchObject({ targetDepth: 2, targetLayerId: "L2" });
239
405
  });
240
406
 
407
+ test("missing handler error names known handlers", async () => {
408
+ const partialRuntime = {
409
+ // showDialog is missing on purpose so we can verify the error message.
410
+ mountLayer: () => {},
411
+ lockScroll: () => {},
412
+ pushHistory: () => {},
413
+ persistSnapshot: () => {},
414
+ };
415
+ Object.setPrototypeOf(partialRuntime, {
416
+ mountLayer: partialRuntime.mountLayer,
417
+ lockScroll: partialRuntime.lockScroll,
418
+ pushHistory: partialRuntime.pushHistory,
419
+ persistSnapshot: partialRuntime.persistSnapshot,
420
+ });
421
+ const orch = new Orchestrator({
422
+ runtime: partialRuntime,
423
+ stackId: STACK_ID,
424
+ baseUrl: BASE_URL,
425
+ });
426
+ let caught = null;
427
+ try {
428
+ await orch.push({ id: "L1", url: "/x" });
429
+ } catch (e) {
430
+ caught = e;
431
+ }
432
+ expect(caught).toBeInstanceOf(Error);
433
+ expect(caught.message).toMatch(/showDialog/);
434
+ expect(caught.message).toMatch(/depth=/);
435
+ });
436
+
437
+ test("max_depth strategy is threaded through to the reducer", async () => {
438
+ const orch = new Orchestrator({
439
+ runtime: recordingRuntime(),
440
+ stackId: STACK_ID,
441
+ baseUrl: BASE_URL,
442
+ maxDepth: 1,
443
+ maxDepthStrategy: "raise",
444
+ });
445
+ await orch.push({ id: "L1", url: "/x" });
446
+ let caught = null;
447
+ try {
448
+ await orch.push({ id: "L2", url: "/y" });
449
+ } catch (e) {
450
+ caught = e;
451
+ }
452
+ expect(caught).not.toBeNull();
453
+ expect(caught.name).toBe("ModalStackDepthError");
454
+ expect(orch.depth).toBe(1);
455
+ });
456
+
241
457
  test("popstate from a different stack tears down without history changes", async () => {
242
458
  await orchestrator.push({ id: "L1", url: "/x" });
243
459
  runtime._calls.length = 0;
@@ -249,8 +465,8 @@ describe("onPopstate", () => {
249
465
 
250
466
  const types = runtime._calls.map((c) => c.type);
251
467
  expect(types).toEqual([
252
- "unmountAllLayers",
253
468
  "closeDialog",
469
+ "unmountAllLayers",
254
470
  "unlockScroll",
255
471
  "clearSnapshot",
256
472
  ]);
@@ -1,12 +1,36 @@
1
1
  export const SNAPSHOT_KEY = "modalStackSnapshot";
2
2
  export const FRAGMENT_HEADER = "X-Modal-Stack-Request";
3
+ export const SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
3
4
 
4
5
  const LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
5
- // Hard cap: never wait longer than this for an exit transition to fire,
6
- // even if the host CSS forgot to transition the leaving state.
7
- const LEAVE_TIMEOUT_MS = 600;
6
+ // CSS variable host stylesheets set to declare their leave-transition
7
+ // duration (e.g. "220ms"). When present, the runtime sizes its safety
8
+ // timeout from this value; otherwise it falls back to a conservative cap.
9
+ const DURATION_CSS_VAR = "--modal-stack-duration";
10
+ // Floor for the safety timeout — even very short CSS transitions need
11
+ // enough headroom for transitionend to fire on slow devices.
12
+ const LEAVE_TIMEOUT_FLOOR_MS = 300;
13
+ // Used when no CSS variable is exposed (host CSS missing, JSDOM tests).
14
+ const LEAVE_TIMEOUT_FALLBACK_MS = 600;
8
15
 
16
+ /**
17
+ * The only file that touches `<dialog>`, `history`, `fetch`, and
18
+ * `sessionStorage`. Implements one method per command type emitted by the
19
+ * reducer in `state.js`.
20
+ *
21
+ * Tests can swap in any object that implements the same surface (see
22
+ * `orchestrator.test.js` for an in-memory fake).
23
+ */
9
24
  export class BrowserRuntime {
25
+ /**
26
+ * @param {Object} options
27
+ * @param {HTMLDialogElement} options.dialog
28
+ * @param {HTMLElement} [options.body]
29
+ * @param {History} [options.history]
30
+ * @param {typeof fetch} [options.fetcher]
31
+ * @param {Storage} [options.store]
32
+ * @param {Document} [options.documentRef]
33
+ */
10
34
  constructor({
11
35
  dialog,
12
36
  body = globalThis.document?.body,
@@ -35,11 +59,27 @@ export class BrowserRuntime {
35
59
  }
36
60
 
37
61
  lockScroll() {
38
- if (this.body) this.body.dataset.modalStackLocked = "";
62
+ if (!this.body) return;
63
+ // Compensate for the scrollbar that disappears once <body> stops
64
+ // overflowing — without this, fixed elements jump rightward by the
65
+ // scrollbar width on lock. Host CSS reads the variable via
66
+ // `padding-right: var(--modal-stack-scrollbar-width, 0)`.
67
+ const root = this.document?.documentElement;
68
+ if (root) {
69
+ const scrollbarWidth = Math.max(
70
+ 0,
71
+ (globalThis.innerWidth ?? root.clientWidth) - root.clientWidth,
72
+ );
73
+ root.style.setProperty(SCROLLBAR_WIDTH_VAR, `${scrollbarWidth}px`);
74
+ }
75
+ this.body.dataset.modalStackLocked = "";
39
76
  }
40
77
 
41
78
  unlockScroll() {
42
- if (this.body) delete this.body.dataset.modalStackLocked;
79
+ if (!this.body) return;
80
+ delete this.body.dataset.modalStackLocked;
81
+ const root = this.document?.documentElement;
82
+ if (root) root.style.removeProperty(SCROLLBAR_WIDTH_VAR);
43
83
  }
44
84
 
45
85
  inertLayer({ layerId, value }) {
@@ -68,12 +108,13 @@ export class BrowserRuntime {
68
108
  async unmountTopLayer() {
69
109
  const layer = this.#topLayer();
70
110
  if (!layer) return;
71
- await animateOut(layer);
111
+ await animateOut(layer, this.#leaveTimeoutMs());
72
112
  }
73
113
 
74
114
  async unmountAllLayers() {
75
115
  const layers = [...this.dialog.querySelectorAll(LAYER_SELECTOR)];
76
- await Promise.all(layers.map(animateOut));
116
+ const timeout = this.#leaveTimeoutMs();
117
+ await Promise.all(layers.map((l) => animateOut(l, timeout)));
77
118
  }
78
119
 
79
120
  pushHistory({ url, historyState }) {
@@ -122,6 +163,34 @@ export class BrowserRuntime {
122
163
  }
123
164
  }
124
165
 
166
+ // Reads --modal-stack-duration from the dialog's computed style and
167
+ // returns 1.5× that as the safety timeout (in ms). Cached after the
168
+ // first successful read since the variable is host-CSS-defined and
169
+ // shouldn't change at runtime. Returns LEAVE_TIMEOUT_FALLBACK_MS when
170
+ // getComputedStyle is unavailable (tests) or the variable is missing.
171
+ #leaveTimeoutMs() {
172
+ if (this._cachedLeaveTimeoutMs != null) return this._cachedLeaveTimeoutMs;
173
+
174
+ const get = globalThis.getComputedStyle;
175
+ if (typeof get !== "function" || !this.dialog?.ownerDocument) {
176
+ return LEAVE_TIMEOUT_FALLBACK_MS;
177
+ }
178
+
179
+ let parsed = NaN;
180
+ try {
181
+ const raw = get(this.dialog).getPropertyValue(DURATION_CSS_VAR);
182
+ parsed = parseDurationMs(raw);
183
+ } catch {
184
+ // getComputedStyle can throw in detached/foreign documents.
185
+ }
186
+
187
+ const ms = Number.isFinite(parsed)
188
+ ? Math.max(Math.ceil(parsed * 1.5), LEAVE_TIMEOUT_FLOOR_MS)
189
+ : LEAVE_TIMEOUT_FALLBACK_MS;
190
+ this._cachedLeaveTimeoutMs = ms;
191
+ return ms;
192
+ }
193
+
125
194
  #findLayer(layerId) {
126
195
  return this.dialog.querySelector(
127
196
  `${LAYER_SELECTOR}[data-layer-id="${escapeAttr(layerId)}"]`,
@@ -159,13 +228,14 @@ export class BrowserRuntime {
159
228
  }
160
229
  }
161
230
 
162
- async fetchFragment(url) {
231
+ async fetchFragment(url, { signal } = {}) {
163
232
  const resp = await this.fetcher(url, {
164
233
  headers: {
165
234
  Accept: "text/html, text/vnd.turbo-stream.html",
166
235
  [FRAGMENT_HEADER]: "1",
167
236
  },
168
237
  credentials: "same-origin",
238
+ signal,
169
239
  });
170
240
  if (!resp.ok) {
171
241
  throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
@@ -193,7 +263,7 @@ function parseFragment(html, doc) {
193
263
  // out, then awaits transitionend (with a hard timeout) before removing
194
264
  // the element from the DOM. If the host CSS doesn't define an exit
195
265
  // transition, the timeout still fires and the layer is removed cleanly.
196
- function animateOut(layer) {
266
+ function animateOut(layer, timeoutMs = LEAVE_TIMEOUT_FALLBACK_MS) {
197
267
  return new Promise((resolve) => {
198
268
  let done = false;
199
269
  const finish = () => {
@@ -205,10 +275,21 @@ function animateOut(layer) {
205
275
  };
206
276
  layer.addEventListener("transitionend", finish, { once: true });
207
277
  layer.dataset.leaving = "";
208
- setTimeout(finish, LEAVE_TIMEOUT_MS);
278
+ setTimeout(finish, timeoutMs);
209
279
  });
210
280
  }
211
281
 
282
+ // Parses a CSS time token ("220ms", "0.22s", " 220 ms ") to milliseconds.
283
+ // Returns NaN when the input is empty or unparseable so callers can fall back.
284
+ function parseDurationMs(raw) {
285
+ if (typeof raw !== "string") return NaN;
286
+ const value = raw.trim();
287
+ if (!value) return NaN;
288
+ const num = parseFloat(value);
289
+ if (!Number.isFinite(num)) return NaN;
290
+ return /m?s$/i.test(value) && !/ms$/i.test(value) ? num * 1000 : num;
291
+ }
292
+
212
293
  function escapeAttr(value) {
213
294
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
214
295
  return CSS.escape(value);