modal_stack 0.3.0 → 0.4.2

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +187 -36
  4. data/app/assets/javascripts/modal_stack.js +693 -73
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
  6. data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
  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 +161 -8
  11. data/app/javascript/modal_stack/install.js +7 -1
  12. data/app/javascript/modal_stack/orchestrator.js +70 -10
  13. data/app/javascript/modal_stack/orchestrator.test.js +98 -2
  14. data/app/javascript/modal_stack/runtime.js +316 -9
  15. data/app/javascript/modal_stack/runtime.test.js +90 -6
  16. data/app/javascript/modal_stack/state.js +343 -45
  17. data/app/javascript/modal_stack/state.test.js +404 -17
  18. data/app/views/modal_stack/_dialog.html.erb +1 -0
  19. data/app/views/modal_stack/_panel.html.erb +4 -0
  20. data/lib/generators/modal_stack/install/templates/initializer.rb +9 -0
  21. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  22. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  23. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  24. data/lib/modal_stack/capybara.rb +21 -0
  25. data/lib/modal_stack/configuration.rb +37 -16
  26. data/lib/modal_stack/engine.rb +2 -0
  27. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  28. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +7 -1
  29. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  30. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  31. data/lib/modal_stack/version.rb +1 -1
  32. data/lib/modal_stack.rb +5 -1
  33. metadata +9 -2
@@ -1,6 +1,7 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
  import { Orchestrator } from "../orchestrator.js";
3
3
  import { BrowserRuntime } from "../runtime.js";
4
+ import { restore } from "../state.js";
4
5
 
5
6
  export class ModalStackController extends Controller {
6
7
  static values = {
@@ -10,33 +11,56 @@ export class ModalStackController extends Controller {
10
11
  maxDepthStrategy: { type: String, default: "warn" },
11
12
  };
12
13
 
14
+ #restoring = false;
15
+
13
16
  connect() {
14
- const stackId = this.stackIdValue || generateLayerId();
15
17
  const baseUrl = this.baseUrlValue || window.location.href;
16
18
 
17
19
  this.runtime = new BrowserRuntime({ dialog: this.element });
18
- const snapshot = this.runtime.readSnapshot();
20
+ // Restore frame HTML cache before reading snapshot so wizard frames
21
+ // saved in sessionStorage are available during #restoreSnapshot.
22
+ this.runtime.restoreFrameCacheFromStorage();
23
+ const savedSnapshot = this.runtime.readSnapshot();
24
+
25
+ // Peek at the snapshot (without stackId filter) to reuse the saved
26
+ // stackId across page reloads — otherwise a randomly generated stackId
27
+ // would never match the one saved in sessionStorage.
28
+ const snapshotState = savedSnapshot ? restore(savedSnapshot) : null;
29
+ const stackId =
30
+ this.stackIdValue || snapshotState?.stackId || generateLayerId();
19
31
 
20
32
  this.orchestrator = new Orchestrator({
21
33
  runtime: this.runtime,
22
34
  stackId,
23
35
  baseUrl,
24
- restoreFrom: snapshot,
36
+ // Restoration is handled below via push() so each layer gets a
37
+ // phantom history entry and the back button closes them one by one.
38
+ restoreFrom: null,
25
39
  // Stimulus Number values default to 0, but state.js treats null as
26
40
  // "no cap" — so map 0/missing to null here.
27
41
  maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
28
42
  maxDepthStrategy: this.maxDepthStrategyValue || "warn",
29
43
  });
30
44
 
31
- this._onPopstate = (event) =>
45
+ this._onPopstate = (event) => {
46
+ // Run in capture phase so we fire before Turbo's bubble-phase popstate
47
+ // handler. When the popstate was triggered by our own historyBack
48
+ // (expectedPopstates > 0), stop propagation immediately after processing
49
+ // so Turbo never sees the event and cannot start a restoration visit
50
+ // (which shows the loading bar and replaces the body).
51
+ const isOwn = this.orchestrator.expectedPopstates > 0;
32
52
  this.orchestrator.onPopstate({
33
53
  historyState: event.state,
34
54
  locationHref: window.location.href,
35
55
  });
36
- window.addEventListener("popstate", this._onPopstate);
56
+ if (isOwn) event.stopImmediatePropagation();
57
+ };
58
+ window.addEventListener("popstate", this._onPopstate, true);
37
59
 
38
60
  this._onCancel = (event) => {
39
61
  event.preventDefault();
62
+ if (!this.element.open) return;
63
+ if (this.#restoring) return;
40
64
  const top = this.#topLayer();
41
65
  if (!top || top.dismissible === false) return;
42
66
  this.orchestrator.pop();
@@ -45,22 +69,98 @@ export class ModalStackController extends Controller {
45
69
 
46
70
  this._onBackdropClick = (event) => {
47
71
  if (event.target !== this.element) return;
72
+ if (!this.element.open) return;
73
+ if (this.#restoring) return;
48
74
  const top = this.#topLayer();
49
75
  if (!top || top.dismissible === false) return;
50
76
  this.orchestrator.pop();
51
77
  };
52
78
  this.element.addEventListener("click", this._onBackdropClick);
53
79
 
80
+ // After any Turbo render (restoration, morph, stream-driven page update),
81
+ // re-check scroll lock. A snapshot cached while a modal was open can
82
+ // restore data-modal-stack-locked on body even after the modal has closed.
83
+ // turbo:before-cache strips the attribute before caching; this is the
84
+ // safety net for renders that fire from an already-stale cache.
85
+ this._onTurboRender = () => {
86
+ if (this.orchestrator.depth === 0) this.runtime.unlockScroll();
87
+ };
88
+ document.addEventListener("turbo:render", this._onTurboRender);
89
+
54
90
  this.#registerStreamActions();
91
+
92
+ if (snapshotState?.layers?.length > 0) {
93
+ this.#restoring = true;
94
+ this.#restoreSnapshot(snapshotState.layers)
95
+ .catch((err) =>
96
+ console.warn("[modal_stack] snapshot restore failed:", err),
97
+ )
98
+ .finally(() => {
99
+ this.#restoring = false;
100
+ });
101
+ }
102
+
55
103
  this.element.dispatchEvent(
56
- new CustomEvent("modal_stack:ready", { bubbles: true, detail: { stackId } }),
104
+ new CustomEvent("modal_stack:ready", {
105
+ bubbles: true,
106
+ detail: { stackId },
107
+ }),
57
108
  );
58
109
  }
59
110
 
111
+ async #restoreSnapshot(layers) {
112
+ // Always open each layer from its first frame URL (accessible via GET).
113
+ const baseUrls = layers.map((l) => l.frames?.[0]?.url ?? l.url);
114
+
115
+ // Pre-fetch base frames in parallel so the push loop runs without any
116
+ // network await between iterations, eliminating the race window where
117
+ // Escape fires while this.state lags behind (only partial stack).
118
+ const baseFragments = await Promise.all(
119
+ baseUrls.map((url) => this.orchestrator.prefetch(url).catch(() => null)),
120
+ );
121
+
122
+ for (let i = 0; i < layers.length; i++) {
123
+ const layer = layers[i];
124
+ await this.orchestrator.push(
125
+ {
126
+ id: layer.id,
127
+ url: baseUrls[i],
128
+ variant: layer.variant,
129
+ dismissible: layer.dismissible,
130
+ size: layer.size,
131
+ side: layer.side,
132
+ width: layer.width,
133
+ height: layer.height,
134
+ },
135
+ { fragment: baseFragments[i] },
136
+ );
137
+
138
+ // Restore additional wizard frames using HTML saved to sessionStorage
139
+ // on the previous visit. Each frame may be a POST-only step that 404s
140
+ // on a direct GET — we use the cached HTML instead of re-fetching.
141
+ const extraFrames = (layer.frames ?? []).slice(1);
142
+ for (let fi = 0; fi < extraFrames.length; fi++) {
143
+ const frame = extraFrames[fi];
144
+ const frameIndex = fi + 1;
145
+ const cached = this.runtime.getFrameFragment(layer.id, frameIndex);
146
+ if (!cached) break; // Can't restore beyond this frame — stop here
147
+ // Warm the orchestrator's fragment cache so forward re-navigation
148
+ // after a back doesn't attempt a failing GET for this URL.
149
+ this.orchestrator.setFragmentCache(frame.url, cached.cloneNode(true));
150
+ await this.orchestrator.pathTo(
151
+ { url: frame.url, stale: frame.stale },
152
+ { fragment: cached.cloneNode(true) },
153
+ );
154
+ }
155
+ }
156
+ }
157
+
60
158
  disconnect() {
61
- window.removeEventListener("popstate", this._onPopstate);
159
+ window.removeEventListener("popstate", this._onPopstate, true);
62
160
  this.element.removeEventListener("cancel", this._onCancel);
63
161
  this.element.removeEventListener("click", this._onBackdropClick);
162
+ document.removeEventListener("turbo:render", this._onTurboRender);
163
+ this.runtime.destroy?.();
64
164
  }
65
165
 
66
166
  push(layer, opts) {
@@ -83,6 +183,17 @@ export class ModalStackController extends Controller {
83
183
  return this.orchestrator.prefetch(url);
84
184
  }
85
185
 
186
+ // Stimulus action — wire up via data-action="click->modal-stack#pathBack"
187
+ // on any button/link inside a modal panel.
188
+ pathBack(event) {
189
+ if (event) {
190
+ event.preventDefault();
191
+ event.stopPropagation();
192
+ }
193
+ const steps = readSteps(event);
194
+ return this.orchestrator.pathBack({ steps });
195
+ }
196
+
86
197
  #topLayer() {
87
198
  const layers = this.orchestrator.layers;
88
199
  return layers[layers.length - 1] ?? null;
@@ -136,6 +247,21 @@ export class ModalStackController extends Controller {
136
247
  StreamActions.modal_close_all = guarded("modal_close_all", function (orch) {
137
248
  return orch.closeAll();
138
249
  });
250
+
251
+ StreamActions.modal_path_to = guarded("modal_path_to", function (orch) {
252
+ return orch.pathTo(frameFromStreamElement(this), {
253
+ fragment: this.templateContent.cloneNode(true),
254
+ transition: this.dataset.transition || null,
255
+ });
256
+ });
257
+
258
+ StreamActions.modal_path_back = guarded("modal_path_back", function (orch) {
259
+ const steps = parsePositiveInt(this.dataset.steps, 1);
260
+ return orch.pathBack({
261
+ steps,
262
+ transition: this.dataset.transition || null,
263
+ });
264
+ });
139
265
  }
140
266
  }
141
267
 
@@ -181,8 +307,35 @@ function layerPatchFromStreamElement(el) {
181
307
  }
182
308
 
183
309
  function generateLayerId() {
184
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
310
+ if (
311
+ typeof crypto !== "undefined" &&
312
+ typeof crypto.randomUUID === "function"
313
+ ) {
185
314
  return crypto.randomUUID();
186
315
  }
187
316
  return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
188
317
  }
318
+
319
+ function frameFromStreamElement(el) {
320
+ return {
321
+ url: el.dataset.url || window.location.href,
322
+ stale: el.dataset.stale === "true" || el.dataset.stale === "1",
323
+ };
324
+ }
325
+
326
+ function parsePositiveInt(raw, fallback) {
327
+ const n = Number.parseInt(raw, 10);
328
+ return Number.isFinite(n) && n > 0 ? n : fallback;
329
+ }
330
+
331
+ // Steps for pathBack come from either Stimulus action params
332
+ // (data-modal-stack-steps-param) or a plain data-steps attribute on
333
+ // the action target, e.g. <button data-modal-stack-steps-param="2">.
334
+ function readSteps(event) {
335
+ const params = event?.params;
336
+ if (params && Number.isFinite(params.steps) && params.steps > 0) {
337
+ return params.steps;
338
+ }
339
+ const target = event?.currentTarget ?? event?.target;
340
+ return parsePositiveInt(target?.dataset?.steps, 1);
341
+ }
@@ -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,
@@ -69,6 +71,10 @@ export class Orchestrator {
69
71
  return this.state.layers.length;
70
72
  }
71
73
 
74
+ get expectedPopstates() {
75
+ return this.#expectedPopstates;
76
+ }
77
+
72
78
  /**
73
79
  * Push a layer. When `html`/`fragment` are absent, the orchestrator
74
80
  * pre-fetches the URL so `mountLayer` is a sync DOM append (no flash).
@@ -104,25 +110,65 @@ export class Orchestrator {
104
110
  return this.#dispatch(replaceTop(this.state, patch, opts), { html, fragment });
105
111
  }
106
112
 
113
+ /**
114
+ * Append a frame to the top layer's path.
115
+ * @param {{ url: string, stale?: boolean }} frame
116
+ * @param {{ html?: string|null, fragment?: DocumentFragment|null, transition?: string|null }} [options]
117
+ */
118
+ async pathTo(frame, { html = null, fragment = null, transition = null } = {}) {
119
+ let resolvedStale = frame?.stale === true;
120
+ if (fragment == null && html == null && frame?.url) {
121
+ const meta = await this.#prefetchWithMeta(frame.url);
122
+ fragment = meta.fragment;
123
+ // The caller's explicit `stale: true` always wins; if they didn't say,
124
+ // honor the X-Modal-Stack-Stale response header surfaced by the runtime.
125
+ if (frame.stale !== true && meta.stale === true) resolvedStale = true;
126
+ }
127
+ return this.#dispatch(
128
+ pathTo(this.state, { url: frame.url, stale: resolvedStale }, { transition }),
129
+ { html, fragment },
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Step back through frames in the top layer's path.
135
+ * @param {{ steps?: number, transition?: string|null }} [options]
136
+ */
137
+ pathBack({ steps = 1, transition = null } = {}) {
138
+ return this.#dispatch(pathBack(this.state, { steps, transition }));
139
+ }
140
+
107
141
  async #prefetch(url) {
108
- if (typeof this.runtime.fetchFragment !== "function") return null;
142
+ const meta = await this.#prefetchWithMeta(url);
143
+ return meta.fragment;
144
+ }
145
+
146
+ async #prefetchWithMeta(url) {
147
+ if (typeof this.runtime.fetchFragment !== "function") {
148
+ return { fragment: null, stale: false };
149
+ }
109
150
 
110
151
  const cached = this.#fragmentCache.get(url);
111
152
  if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
112
- return cloneFragment(cached.fragment);
153
+ return { fragment: cloneFragment(cached.fragment), stale: cached.stale === true };
113
154
  }
114
155
 
115
156
  const existing = this.#inflight.get(url);
116
157
  if (existing) {
117
158
  const entry = await existing.promise;
118
- return cloneFragment(entry.fragment);
159
+ return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
119
160
  }
120
161
 
121
162
  const controller = supportsAbort() ? new AbortController() : null;
122
163
  const fetchPromise = this.runtime
123
164
  .fetchFragment(url, controller ? { signal: controller.signal } : undefined)
124
- .then((fragment) => {
125
- const entry = { fragment, ts: Date.now() };
165
+ .then((result) => {
166
+ // BrowserRuntime returns { fragment, stale }; older test fakes
167
+ // (and prior behavior) returned a bare DocumentFragment — accept
168
+ // both so we don't lock the runtime contract too tightly.
169
+ const fragment = result?.fragment ?? result;
170
+ const stale = result?.stale === true;
171
+ const entry = { fragment, stale, ts: Date.now() };
126
172
  this.#fragmentCache.set(url, entry);
127
173
  return entry;
128
174
  })
@@ -132,7 +178,7 @@ export class Orchestrator {
132
178
 
133
179
  this.#inflight.set(url, { controller, promise: fetchPromise });
134
180
  const entry = await fetchPromise;
135
- return cloneFragment(entry.fragment);
181
+ return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
136
182
  }
137
183
 
138
184
  // Aborts every in-flight prefetch and forgets any cached fragments.
@@ -152,6 +198,18 @@ export class Orchestrator {
152
198
  this.#fragmentCache.clear();
153
199
  }
154
200
 
201
+ // Seed the fragment cache with a known fragment for a URL. Used during
202
+ // snapshot restore to ensure forward re-navigation to a POST-only wizard
203
+ // step reuses the cached HTML rather than attempting a failing GET fetch.
204
+ setFragmentCache(url, fragment) {
205
+ if (!url || !fragment) return;
206
+ this.#fragmentCache.set(url, {
207
+ fragment: cloneFragment(fragment),
208
+ stale: false,
209
+ ts: Date.now(),
210
+ });
211
+ }
212
+
155
213
  // Warm the prefetch cache for `url` without mutating the stack. Safe
156
214
  // to call repeatedly for the same URL (deduped via #inflight) and from
157
215
  // hover/focus handlers; failures are swallowed since this is best-effort.
@@ -175,15 +233,17 @@ export class Orchestrator {
175
233
  // A popstate arriving while we have prefetches in flight means the
176
234
  // user navigated away from any URL we were preloading; drop them.
177
235
  this.#invalidatePrefetch();
178
- return this.#dispatch(
179
- handlePopstate(this.state, { historyState, locationHref }),
180
- );
236
+ return this.#dispatch(handlePopstate(this.state, { historyState, locationHref }));
181
237
  }
182
238
 
183
239
  async #dispatch({ state, commands }, payload = {}) {
184
240
  this.state = state;
185
241
  for (const cmd of commands) {
186
- if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer") {
242
+ if (
243
+ cmd.type === "mountLayer" ||
244
+ cmd.type === "morphTopLayer" ||
245
+ cmd.type === "mountFrame"
246
+ ) {
187
247
  if (payload.html != null) cmd.html = payload.html;
188
248
  if (payload.fragment != null) cmd.fragment = payload.fragment;
189
249
  }
@@ -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" });
@@ -209,10 +299,12 @@ describe("closeAll", () => {
209
299
  const types = runtime._calls.map((c) => c.type);
210
300
  expect(types).toEqual([
211
301
  "closeDialog",
302
+ "clearSnapshot",
212
303
  "unmountAllLayers",
304
+ "clearFrameCache",
305
+ "clearFrameCache",
213
306
  "unlockScroll",
214
307
  "historyBack",
215
- "clearSnapshot",
216
308
  ]);
217
309
 
218
310
  runtime._calls.length = 0;
@@ -256,6 +348,9 @@ describe("prefetch cache + abort", () => {
256
348
  "rebuildFromSnapshot",
257
349
  "persistSnapshot",
258
350
  "clearSnapshot",
351
+ "mountFrame",
352
+ "unmountFrame",
353
+ "clearFrameCache",
259
354
  ];
260
355
  const runtime = { _calls: calls, _fetches: [], _aborts: aborts };
261
356
  for (const name of handlerNames) {
@@ -466,9 +561,10 @@ describe("onPopstate", () => {
466
561
  const types = runtime._calls.map((c) => c.type);
467
562
  expect(types).toEqual([
468
563
  "closeDialog",
564
+ "clearSnapshot",
469
565
  "unmountAllLayers",
566
+ "clearFrameCache",
470
567
  "unlockScroll",
471
- "clearSnapshot",
472
568
  ]);
473
569
  expect(types).not.toContain("historyBack");
474
570
  });