modal_stack 0.2.0 → 0.4.1

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +136 -52
  4. data/app/assets/javascripts/modal_stack.js +612 -63
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +120 -11
  6. data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +82 -14
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +372 -0
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +128 -11
  9. data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
  10. data/app/javascript/modal_stack/controllers/modal_stack_controller.js +54 -0
  11. data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
  12. data/app/javascript/modal_stack/install.js +7 -1
  13. data/app/javascript/modal_stack/orchestrator.js +132 -3
  14. data/app/javascript/modal_stack/orchestrator.test.js +264 -2
  15. data/app/javascript/modal_stack/runtime.js +222 -13
  16. data/app/javascript/modal_stack/runtime.test.js +151 -0
  17. data/app/javascript/modal_stack/state.js +338 -39
  18. data/app/javascript/modal_stack/state.test.js +400 -13
  19. data/app/views/modal_stack/_dialog.html.erb +1 -0
  20. data/app/views/modal_stack/_panel.html.erb +4 -0
  21. data/lib/generators/modal_stack/install/install_generator.rb +18 -4
  22. data/lib/generators/modal_stack/install/templates/initializer.rb +21 -5
  23. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  24. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  25. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  26. data/lib/modal_stack/capybara.rb +21 -0
  27. data/lib/modal_stack/configuration.rb +43 -17
  28. data/lib/modal_stack/controller_extensions.rb +8 -1
  29. data/lib/modal_stack/engine.rb +2 -0
  30. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  31. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +15 -3
  32. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  33. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  34. data/lib/modal_stack/version.rb +1 -1
  35. data/lib/modal_stack.rb +5 -1
  36. metadata +11 -3
@@ -79,6 +79,21 @@ export class ModalStackController extends Controller {
79
79
  return this.orchestrator.closeAll();
80
80
  }
81
81
 
82
+ prefetch(url) {
83
+ return this.orchestrator.prefetch(url);
84
+ }
85
+
86
+ // Stimulus action — wire up via data-action="click->modal-stack#pathBack"
87
+ // on any button/link inside a modal panel.
88
+ pathBack(event) {
89
+ if (event) {
90
+ event.preventDefault();
91
+ event.stopPropagation();
92
+ }
93
+ const steps = readSteps(event);
94
+ return this.orchestrator.pathBack({ steps });
95
+ }
96
+
82
97
  #topLayer() {
83
98
  const layers = this.orchestrator.layers;
84
99
  return layers[layers.length - 1] ?? null;
@@ -132,6 +147,21 @@ export class ModalStackController extends Controller {
132
147
  StreamActions.modal_close_all = guarded("modal_close_all", function (orch) {
133
148
  return orch.closeAll();
134
149
  });
150
+
151
+ StreamActions.modal_path_to = guarded("modal_path_to", function (orch) {
152
+ return orch.pathTo(frameFromStreamElement(this), {
153
+ fragment: this.templateContent.cloneNode(true),
154
+ transition: this.dataset.transition || null,
155
+ });
156
+ });
157
+
158
+ StreamActions.modal_path_back = guarded("modal_path_back", function (orch) {
159
+ const steps = parsePositiveInt(this.dataset.steps, 1);
160
+ return orch.pathBack({
161
+ steps,
162
+ transition: this.dataset.transition || null,
163
+ });
164
+ });
135
165
  }
136
166
  }
137
167
 
@@ -182,3 +212,27 @@ function generateLayerId() {
182
212
  }
183
213
  return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
184
214
  }
215
+
216
+ function frameFromStreamElement(el) {
217
+ return {
218
+ url: el.dataset.url || window.location.href,
219
+ stale: el.dataset.stale === "true" || el.dataset.stale === "1",
220
+ };
221
+ }
222
+
223
+ function parsePositiveInt(raw, fallback) {
224
+ const n = Number.parseInt(raw, 10);
225
+ return Number.isFinite(n) && n > 0 ? n : fallback;
226
+ }
227
+
228
+ // Steps for pathBack come from either Stimulus action params
229
+ // (data-modal-stack-steps-param) or a plain data-steps attribute on
230
+ // the action target, e.g. <button data-modal-stack-steps-param="2">.
231
+ function readSteps(event) {
232
+ const params = event?.params;
233
+ if (params && Number.isFinite(params.steps) && params.steps > 0) {
234
+ return params.steps;
235
+ }
236
+ const target = event?.currentTarget ?? event?.target;
237
+ return parsePositiveInt(target?.dataset?.steps, 1);
238
+ }
@@ -1,14 +1,21 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  export class ModalStackLinkController extends Controller {
4
- open(event) {
5
- const stack = document.querySelector('[data-controller~="modal-stack"]');
6
- if (!stack) return;
4
+ connect() {
5
+ if (this.element.dataset.modalStackLinkPrefetch === "false") return;
6
+ this._onIntent = () => this.#warm();
7
+ this.element.addEventListener("pointerenter", this._onIntent);
8
+ this.element.addEventListener("focus", this._onIntent);
9
+ }
7
10
 
8
- const controller = this.application.getControllerForElementAndIdentifier(
9
- stack,
10
- "modal-stack",
11
- );
11
+ disconnect() {
12
+ if (!this._onIntent) return;
13
+ this.element.removeEventListener("pointerenter", this._onIntent);
14
+ this.element.removeEventListener("focus", this._onIntent);
15
+ }
16
+
17
+ open(event) {
18
+ const controller = this.#stackController();
12
19
  if (!controller) return;
13
20
 
14
21
  event.preventDefault();
@@ -24,6 +31,21 @@ export class ModalStackLinkController extends Controller {
24
31
  dismissible: ds.modalStackLinkDismissible !== "false",
25
32
  });
26
33
  }
34
+
35
+ #warm() {
36
+ const controller = this.#stackController();
37
+ if (!controller || typeof controller.prefetch !== "function") return;
38
+ controller.prefetch(this.element.href);
39
+ }
40
+
41
+ #stackController() {
42
+ const stack = document.querySelector('[data-controller~="modal-stack"]');
43
+ if (!stack) return null;
44
+ return this.application.getControllerForElementAndIdentifier(
45
+ stack,
46
+ "modal-stack",
47
+ );
48
+ }
27
49
  }
28
50
 
29
51
  function generateLayerId() {
@@ -1,3 +1,4 @@
1
+ import { ModalStackBackLinkController } from "./controllers/modal_stack_back_link_controller.js";
1
2
  import { ModalStackController } from "./controllers/modal_stack_controller.js";
2
3
  import { ModalStackLinkController } from "./controllers/modal_stack_link_controller.js";
3
4
 
@@ -9,7 +10,12 @@ export function install(application) {
9
10
  }
10
11
  application.register("modal-stack", ModalStackController);
11
12
  application.register("modal-stack-link", ModalStackLinkController);
13
+ application.register("modal-stack-back-link", ModalStackBackLinkController);
12
14
  return application;
13
15
  }
14
16
 
15
- export { ModalStackController, ModalStackLinkController };
17
+ export {
18
+ ModalStackBackLinkController,
19
+ ModalStackController,
20
+ ModalStackLinkController,
21
+ };
@@ -2,6 +2,8 @@ import {
2
2
  closeAll,
3
3
  createStack,
4
4
  handlePopstate,
5
+ pathBack,
6
+ pathTo,
5
7
  pop,
6
8
  push,
7
9
  replaceTop,
@@ -9,6 +11,11 @@ import {
9
11
  snapshot,
10
12
  } from "./state.js";
11
13
 
14
+ // How long a successful prefetch is reused before being refetched. Short
15
+ // enough that stale server-rendered HTML doesn't linger; long enough to
16
+ // absorb back/forward bounces and rapid double-clicks.
17
+ const PREFETCH_TTL_MS = 30_000;
18
+
12
19
  /**
13
20
  * Owns the current `Stack`, calls the pure reducer, and executes the emitted
14
21
  * commands against an injected runtime. The only stateful piece is
@@ -22,9 +29,16 @@ import {
22
29
  * @property {string|null} [restoreFrom] Serialized snapshot from sessionStorage
23
30
  * @property {number|null} [maxDepth] null disables the cap
24
31
  * @property {"raise"|"warn"|"silent"} [maxDepthStrategy]
32
+ * @property {number} [prefetchTtlMs] Override the prefetch cache TTL (testing)
25
33
  */
26
34
  export class Orchestrator {
27
35
  #expectedPopstates = 0;
36
+ // url → { fragment, ts }. Fragment is the canonical copy; consumers
37
+ // always receive a `cloneNode(true)` so the cached entry stays intact.
38
+ #fragmentCache = new Map();
39
+ // url → { controller, promise }. Lets concurrent prefetches dedupe onto
40
+ // the same in-flight request, and gives `closeAll` a way to cancel them.
41
+ #inflight = new Map();
28
42
 
29
43
  /** @param {OrchestratorOptions} options */
30
44
  constructor({
@@ -34,11 +48,13 @@ export class Orchestrator {
34
48
  restoreFrom = null,
35
49
  maxDepth = null,
36
50
  maxDepthStrategy = "warn",
51
+ prefetchTtlMs = PREFETCH_TTL_MS,
37
52
  }) {
38
53
  if (!runtime) throw new Error("runtime required");
39
54
  this.runtime = runtime;
40
55
  this.maxDepth = maxDepth;
41
56
  this.maxDepthStrategy = maxDepthStrategy;
57
+ this.prefetchTtlMs = prefetchTtlMs;
42
58
  this.state = createStack({ stackId, baseUrl });
43
59
 
44
60
  if (restoreFrom) {
@@ -90,12 +106,106 @@ export class Orchestrator {
90
106
  return this.#dispatch(replaceTop(this.state, patch, opts), { html, fragment });
91
107
  }
92
108
 
109
+ /**
110
+ * Append a frame to the top layer's path.
111
+ * @param {{ url: string, stale?: boolean }} frame
112
+ * @param {{ html?: string|null, fragment?: DocumentFragment|null, transition?: string|null }} [options]
113
+ */
114
+ async pathTo(frame, { html = null, fragment = null, transition = null } = {}) {
115
+ let resolvedStale = frame?.stale === true;
116
+ if (fragment == null && html == null && frame?.url) {
117
+ const meta = await this.#prefetchWithMeta(frame.url);
118
+ fragment = meta.fragment;
119
+ // The caller's explicit `stale: true` always wins; if they didn't say,
120
+ // honor the X-Modal-Stack-Stale response header surfaced by the runtime.
121
+ if (frame.stale !== true && meta.stale === true) resolvedStale = true;
122
+ }
123
+ return this.#dispatch(
124
+ pathTo(this.state, { url: frame.url, stale: resolvedStale }, { transition }),
125
+ { html, fragment },
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Step back through frames in the top layer's path.
131
+ * @param {{ steps?: number, transition?: string|null }} [options]
132
+ */
133
+ pathBack({ steps = 1, transition = null } = {}) {
134
+ return this.#dispatch(pathBack(this.state, { steps, transition }));
135
+ }
136
+
93
137
  async #prefetch(url) {
94
- if (typeof this.runtime.fetchFragment !== "function") return null;
95
- return this.runtime.fetchFragment(url);
138
+ const meta = await this.#prefetchWithMeta(url);
139
+ return meta.fragment;
140
+ }
141
+
142
+ async #prefetchWithMeta(url) {
143
+ if (typeof this.runtime.fetchFragment !== "function") {
144
+ return { fragment: null, stale: false };
145
+ }
146
+
147
+ const cached = this.#fragmentCache.get(url);
148
+ if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
149
+ return { fragment: cloneFragment(cached.fragment), stale: cached.stale === true };
150
+ }
151
+
152
+ const existing = this.#inflight.get(url);
153
+ if (existing) {
154
+ const entry = await existing.promise;
155
+ return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
156
+ }
157
+
158
+ const controller = supportsAbort() ? new AbortController() : null;
159
+ const fetchPromise = this.runtime
160
+ .fetchFragment(url, controller ? { signal: controller.signal } : undefined)
161
+ .then((result) => {
162
+ // BrowserRuntime returns { fragment, stale }; older test fakes
163
+ // (and prior behavior) returned a bare DocumentFragment — accept
164
+ // both so we don't lock the runtime contract too tightly.
165
+ const fragment = result?.fragment ?? result;
166
+ const stale = result?.stale === true;
167
+ const entry = { fragment, stale, ts: Date.now() };
168
+ this.#fragmentCache.set(url, entry);
169
+ return entry;
170
+ })
171
+ .finally(() => {
172
+ this.#inflight.delete(url);
173
+ });
174
+
175
+ this.#inflight.set(url, { controller, promise: fetchPromise });
176
+ const entry = await fetchPromise;
177
+ return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
178
+ }
179
+
180
+ // Aborts every in-flight prefetch and forgets any cached fragments.
181
+ // Called when we tear the stack down (closeAll / cross-stack popstate)
182
+ // because the URLs in flight are no longer relevant. In-flight callers
183
+ // see an AbortError; caller code (controllers) already wraps push/pop
184
+ // in try/catch via `guarded()`.
185
+ #invalidatePrefetch() {
186
+ for (const { controller } of this.#inflight.values()) {
187
+ try {
188
+ controller?.abort();
189
+ } catch {
190
+ // ignore — abort is best-effort
191
+ }
192
+ }
193
+ this.#inflight.clear();
194
+ this.#fragmentCache.clear();
195
+ }
196
+
197
+ // Warm the prefetch cache for `url` without mutating the stack. Safe
198
+ // to call repeatedly for the same URL (deduped via #inflight) and from
199
+ // hover/focus handlers; failures are swallowed since this is best-effort.
200
+ prefetch(url) {
201
+ if (!url || typeof this.runtime.fetchFragment !== "function") {
202
+ return Promise.resolve(null);
203
+ }
204
+ return this.#prefetch(url).catch(() => null);
96
205
  }
97
206
 
98
207
  closeAll() {
208
+ this.#invalidatePrefetch();
99
209
  return this.#dispatch(closeAll(this.state));
100
210
  }
101
211
 
@@ -104,6 +214,9 @@ export class Orchestrator {
104
214
  this.#expectedPopstates -= 1;
105
215
  return Promise.resolve();
106
216
  }
217
+ // A popstate arriving while we have prefetches in flight means the
218
+ // user navigated away from any URL we were preloading; drop them.
219
+ this.#invalidatePrefetch();
107
220
  return this.#dispatch(
108
221
  handlePopstate(this.state, { historyState, locationHref }),
109
222
  );
@@ -112,7 +225,11 @@ export class Orchestrator {
112
225
  async #dispatch({ state, commands }, payload = {}) {
113
226
  this.state = state;
114
227
  for (const cmd of commands) {
115
- if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer") {
228
+ if (
229
+ cmd.type === "mountLayer" ||
230
+ cmd.type === "morphTopLayer" ||
231
+ cmd.type === "mountFrame"
232
+ ) {
116
233
  if (payload.html != null) cmd.html = payload.html;
117
234
  if (payload.fragment != null) cmd.fragment = payload.fragment;
118
235
  }
@@ -145,3 +262,15 @@ export class Orchestrator {
145
262
  await handler.call(this.runtime, cmd);
146
263
  }
147
264
  }
265
+
266
+ function cloneFragment(fragment) {
267
+ if (!fragment) return fragment;
268
+ if (typeof fragment.cloneNode === "function") {
269
+ return fragment.cloneNode(true);
270
+ }
271
+ return fragment;
272
+ }
273
+
274
+ function supportsAbort() {
275
+ return typeof globalThis.AbortController === "function";
276
+ }
@@ -23,6 +23,9 @@ function recordingRuntime() {
23
23
  "rebuildFromSnapshot",
24
24
  "persistSnapshot",
25
25
  "clearSnapshot",
26
+ "mountFrame",
27
+ "unmountFrame",
28
+ "clearFrameCache",
26
29
  ];
27
30
  const runtime = { _calls: calls };
28
31
  for (const name of handlerNames) {
@@ -198,6 +201,93 @@ describe("replaceTop", () => {
198
201
  });
199
202
  });
200
203
 
204
+ describe("pathTo", () => {
205
+ test("appends a frame, dispatches mountFrame + pushHistory + persistSnapshot", async () => {
206
+ await orchestrator.push({ id: "L1", url: "/wizard/1" });
207
+ runtime._calls.length = 0;
208
+
209
+ await orchestrator.pathTo({ url: "/wizard/2" });
210
+ expect(orchestrator.layers[0].frames).toHaveLength(2);
211
+ const types = runtime._calls.map((c) => c.type);
212
+ expect(types).toEqual([
213
+ "mountFrame",
214
+ "pushHistory",
215
+ "persistSnapshot",
216
+ ]);
217
+ const mount = runtime._calls.find((c) => c.type === "mountFrame");
218
+ expect(mount).toMatchObject({
219
+ layerId: "L1",
220
+ fromFrameIndex: 0,
221
+ toFrameIndex: 1,
222
+ url: "/wizard/2",
223
+ stale: false,
224
+ });
225
+ });
226
+
227
+ test("explicit stale: true wins over the response header", async () => {
228
+ await orchestrator.push({ id: "L1", url: "/wizard/1" });
229
+ runtime._calls.length = 0;
230
+
231
+ await orchestrator.pathTo({ url: "/wizard/2", stale: true });
232
+ const mount = runtime._calls.find((c) => c.type === "mountFrame");
233
+ expect(mount.stale).toBe(true);
234
+ expect(orchestrator.layers[0].frames[1].stale).toBe(true);
235
+ });
236
+
237
+ test("threads transition through to the runtime", async () => {
238
+ await orchestrator.push({ id: "L1", url: "/wizard/1" });
239
+ runtime._calls.length = 0;
240
+ await orchestrator.pathTo({ url: "/wizard/2" }, { transition: "fade" });
241
+ expect(runtime._calls.find((c) => c.type === "mountFrame")).toMatchObject({
242
+ transition: "fade",
243
+ });
244
+ });
245
+ });
246
+
247
+ describe("pathBack", () => {
248
+ test("on a single-frame layer is a noop (does not close the layer)", async () => {
249
+ await orchestrator.push({ id: "L1", url: "/wizard/1" });
250
+ runtime._calls.length = 0;
251
+ await orchestrator.pathBack();
252
+ expect(orchestrator.layers[0].frames).toHaveLength(1);
253
+ expect(runtime._calls).toEqual([]);
254
+ });
255
+
256
+ test("steps back through frames and walks history", async () => {
257
+ await orchestrator.push({ id: "L1", url: "/wizard/1" });
258
+ await orchestrator.pathTo({ url: "/wizard/2" });
259
+ await orchestrator.pathTo({ url: "/wizard/3" });
260
+ runtime._calls.length = 0;
261
+
262
+ await orchestrator.pathBack({ steps: 2 });
263
+ expect(orchestrator.layers[0].frames).toHaveLength(1);
264
+ const types = runtime._calls.map((c) => c.type);
265
+ expect(types).toEqual([
266
+ "unmountFrame",
267
+ "historyBack",
268
+ "persistSnapshot",
269
+ ]);
270
+ expect(runtime._calls.find((c) => c.type === "historyBack")).toEqual({
271
+ type: "historyBack",
272
+ n: 2,
273
+ });
274
+ });
275
+
276
+ test("guards the popstate that history.go triggers", async () => {
277
+ await orchestrator.push({ id: "L1", url: "/wizard/1" });
278
+ await orchestrator.pathTo({ url: "/wizard/2" });
279
+ runtime._calls.length = 0;
280
+
281
+ await orchestrator.pathBack();
282
+ runtime._calls.length = 0;
283
+ await orchestrator.onPopstate({
284
+ historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
285
+ locationHref: "/wizard/1",
286
+ });
287
+ expect(runtime._calls).toEqual([]);
288
+ });
289
+ });
290
+
201
291
  describe("closeAll", () => {
202
292
  test("clears layers and increments guard once for the historyBack call", async () => {
203
293
  await orchestrator.push({ id: "L1", url: "/x" });
@@ -208,8 +298,10 @@ describe("closeAll", () => {
208
298
  expect(orchestrator.depth).toBe(0);
209
299
  const types = runtime._calls.map((c) => c.type);
210
300
  expect(types).toEqual([
211
- "unmountAllLayers",
212
301
  "closeDialog",
302
+ "unmountAllLayers",
303
+ "clearFrameCache",
304
+ "clearFrameCache",
213
305
  "unlockScroll",
214
306
  "historyBack",
215
307
  "clearSnapshot",
@@ -224,6 +316,175 @@ describe("closeAll", () => {
224
316
  });
225
317
  });
226
318
 
319
+ describe("prefetch cache + abort", () => {
320
+ // Each fakeFragment supports cloneNode so the orchestrator can hand out
321
+ // independent copies without exhausting the cached entry.
322
+ function fakeFragment(label) {
323
+ return {
324
+ label,
325
+ consumed: false,
326
+ cloneNode() {
327
+ return fakeFragment(label);
328
+ },
329
+ };
330
+ }
331
+
332
+ function fetchingRuntime({ delayMs = 0, fail = false } = {}) {
333
+ const calls = [];
334
+ const aborts = [];
335
+ const handlerNames = [
336
+ "showDialog",
337
+ "lockScroll",
338
+ "inertLayer",
339
+ "mountLayer",
340
+ "morphTopLayer",
341
+ "unmountTopLayer",
342
+ "unmountAllLayers",
343
+ "closeDialog",
344
+ "unlockScroll",
345
+ "pushHistory",
346
+ "replaceHistory",
347
+ "historyBack",
348
+ "rebuildFromSnapshot",
349
+ "persistSnapshot",
350
+ "clearSnapshot",
351
+ "mountFrame",
352
+ "unmountFrame",
353
+ "clearFrameCache",
354
+ ];
355
+ const runtime = { _calls: calls, _fetches: [], _aborts: aborts };
356
+ for (const name of handlerNames) {
357
+ runtime[name] = (cmd) => {
358
+ calls.push({ type: name, ...cmd });
359
+ };
360
+ }
361
+ runtime.fetchFragment = (url, { signal } = {}) => {
362
+ runtime._fetches.push(url);
363
+ return new Promise((resolve, reject) => {
364
+ const t = setTimeout(() => {
365
+ if (fail) reject(new Error("boom"));
366
+ else resolve(fakeFragment(`frag:${url}`));
367
+ }, delayMs);
368
+ if (signal) {
369
+ signal.addEventListener("abort", () => {
370
+ clearTimeout(t);
371
+ aborts.push(url);
372
+ const err = new Error("aborted");
373
+ err.name = "AbortError";
374
+ reject(err);
375
+ });
376
+ }
377
+ });
378
+ };
379
+ return runtime;
380
+ }
381
+
382
+ test("dedupes concurrent prefetches for the same url", async () => {
383
+ const rt = fetchingRuntime({ delayMs: 5 });
384
+ const orch = new Orchestrator({
385
+ runtime: rt,
386
+ stackId: STACK_ID,
387
+ baseUrl: BASE_URL,
388
+ });
389
+ await Promise.all([
390
+ orch.push({ id: "L1", url: "/x" }),
391
+ orch.push({ id: "L2", url: "/x" }),
392
+ ]);
393
+ expect(rt._fetches).toEqual(["/x"]);
394
+ });
395
+
396
+ test("hits the cache on a second push to the same url", async () => {
397
+ const rt = fetchingRuntime();
398
+ const orch = new Orchestrator({
399
+ runtime: rt,
400
+ stackId: STACK_ID,
401
+ baseUrl: BASE_URL,
402
+ });
403
+ await orch.push({ id: "L1", url: "/x" });
404
+ await orch.pop();
405
+ await orch.push({ id: "L2", url: "/x" });
406
+ expect(rt._fetches).toEqual(["/x"]);
407
+ });
408
+
409
+ test("returns a fresh clone per consumer (cache survives consumption)", async () => {
410
+ const rt = fetchingRuntime();
411
+ const orch = new Orchestrator({
412
+ runtime: rt,
413
+ stackId: STACK_ID,
414
+ baseUrl: BASE_URL,
415
+ });
416
+ const seenFragments = [];
417
+ rt.mountLayer = (cmd) => {
418
+ seenFragments.push(cmd.fragment);
419
+ };
420
+ await orch.push({ id: "L1", url: "/x" });
421
+ await orch.pop();
422
+ await orch.push({ id: "L2", url: "/x" });
423
+ expect(seenFragments).toHaveLength(2);
424
+ expect(seenFragments[0]).not.toBe(seenFragments[1]);
425
+ });
426
+
427
+ test("TTL expires the cache and triggers a refetch", async () => {
428
+ const rt = fetchingRuntime();
429
+ const orch = new Orchestrator({
430
+ runtime: rt,
431
+ stackId: STACK_ID,
432
+ baseUrl: BASE_URL,
433
+ prefetchTtlMs: 1,
434
+ });
435
+ await orch.push({ id: "L1", url: "/x" });
436
+ await orch.pop();
437
+ await new Promise((r) => setTimeout(r, 5));
438
+ await orch.push({ id: "L2", url: "/x" });
439
+ expect(rt._fetches).toEqual(["/x", "/x"]);
440
+ });
441
+
442
+ test("prefetch warms the cache without dispatching commands", async () => {
443
+ const rt = fetchingRuntime();
444
+ const orch = new Orchestrator({
445
+ runtime: rt,
446
+ stackId: STACK_ID,
447
+ baseUrl: BASE_URL,
448
+ });
449
+ await orch.prefetch("/x");
450
+ expect(rt._fetches).toEqual(["/x"]);
451
+ expect(rt._calls).toEqual([]);
452
+ // Subsequent push consumes the cache, no second fetch.
453
+ await orch.push({ id: "L1", url: "/x" });
454
+ expect(rt._fetches).toEqual(["/x"]);
455
+ });
456
+
457
+ test("prefetch swallows errors (best-effort)", async () => {
458
+ const rt = fetchingRuntime({ fail: true });
459
+ const orch = new Orchestrator({
460
+ runtime: rt,
461
+ stackId: STACK_ID,
462
+ baseUrl: BASE_URL,
463
+ });
464
+ await expect(orch.prefetch("/boom")).resolves.toBeNull();
465
+ });
466
+
467
+ test("closeAll aborts in-flight prefetches and clears the cache", async () => {
468
+ const rt = fetchingRuntime({ delayMs: 50 });
469
+ const orch = new Orchestrator({
470
+ runtime: rt,
471
+ stackId: STACK_ID,
472
+ baseUrl: BASE_URL,
473
+ });
474
+ // First push completes so the cache has /x.
475
+ await orch.push({ id: "L1", url: "/x" });
476
+ // Second push starts fetching /y and stays in flight.
477
+ const inflight = orch.push({ id: "L2", url: "/y" });
478
+ await new Promise((r) => setTimeout(r, 5));
479
+ await orch.closeAll();
480
+ await expect(inflight).rejects.toThrow(/aborted/);
481
+ expect(rt._aborts).toContain("/y");
482
+ // Cache has been cleared too: re-push of /x must refetch.
483
+ await orch.push({ id: "L3", url: "/x" });
484
+ expect(rt._fetches.filter((u) => u === "/x")).toHaveLength(2);
485
+ });
486
+ });
487
+
227
488
  describe("onPopstate", () => {
228
489
  test("forward navigation requests rebuild from snapshot", async () => {
229
490
  await orchestrator.push({ id: "L1", url: "/x" });
@@ -299,8 +560,9 @@ describe("onPopstate", () => {
299
560
 
300
561
  const types = runtime._calls.map((c) => c.type);
301
562
  expect(types).toEqual([
302
- "unmountAllLayers",
303
563
  "closeDialog",
564
+ "unmountAllLayers",
565
+ "clearFrameCache",
304
566
  "unlockScroll",
305
567
  "clearSnapshot",
306
568
  ]);