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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +73 -26
- data/app/assets/javascripts/modal_stack.js +230 -41
- data/app/assets/stylesheets/modal_stack/bootstrap.css +7 -8
- data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +19 -12
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +311 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +7 -8
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +52 -13
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/orchestrator.js +136 -4
- data/app/javascript/modal_stack/orchestrator.test.js +218 -2
- data/app/javascript/modal_stack/runtime.js +91 -10
- data/app/javascript/modal_stack/runtime.test.js +138 -1
- data/app/javascript/modal_stack/state.js +142 -8
- data/app/javascript/modal_stack/state.test.js +89 -5
- data/lib/generators/modal_stack/install/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +19 -6
- data/lib/modal_stack/configuration.rb +44 -5
- data/lib/modal_stack/controller_extensions.rb +8 -1
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +26 -6
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +1 -1
- metadata +4 -3
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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);
|