modal_stack 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -359,6 +359,41 @@ body[data-modal-stack-locked] {
359
359
  display: none;
360
360
  }
361
361
 
362
+ /* --- Header slot ------------------------------------------------- */
363
+
364
+ .modal-stack__panel-header {
365
+ display: flex;
366
+ align-items: center;
367
+ justify-content: space-between;
368
+ gap: 0.75rem;
369
+ }
370
+
371
+ .modal-stack__panel-title {
372
+ margin: 0;
373
+ flex: 1;
374
+ min-width: 0;
375
+ }
376
+
377
+ .modal-stack__panel-close {
378
+ font: inherit;
379
+ background: transparent;
380
+ border: 0;
381
+ padding: 0.25rem;
382
+ cursor: pointer;
383
+ color: inherit;
384
+ opacity: 0.55;
385
+ line-height: 1;
386
+ font-size: 1.4em;
387
+ flex-shrink: 0;
388
+ transition: opacity 150ms ease;
389
+ }
390
+ .modal-stack__panel-close:hover { opacity: 1; }
391
+ .modal-stack__panel-close:focus-visible {
392
+ outline: 2px solid currentColor;
393
+ outline-offset: 2px;
394
+ border-radius: 2px;
395
+ }
396
+
362
397
  /* --- Reduced motion ---------------------------------------------- */
363
398
 
364
399
  @media (prefers-reduced-motion: reduce) {
@@ -360,6 +360,41 @@ body[data-modal-stack-locked] {
360
360
  display: none;
361
361
  }
362
362
 
363
+ /* --- Header slot ------------------------------------------------- */
364
+
365
+ .modal-stack__panel-header {
366
+ display: flex;
367
+ align-items: center;
368
+ justify-content: space-between;
369
+ gap: 0.75rem;
370
+ }
371
+
372
+ .modal-stack__panel-title {
373
+ margin: 0;
374
+ flex: 1;
375
+ min-width: 0;
376
+ }
377
+
378
+ .modal-stack__panel-close {
379
+ font: inherit;
380
+ background: transparent;
381
+ border: 0;
382
+ padding: 0.25rem;
383
+ cursor: pointer;
384
+ color: inherit;
385
+ opacity: 0.55;
386
+ line-height: 1;
387
+ font-size: 1.4em;
388
+ flex-shrink: 0;
389
+ transition: opacity 150ms ease;
390
+ }
391
+ .modal-stack__panel-close:hover { opacity: 1; }
392
+ .modal-stack__panel-close:focus-visible {
393
+ outline: 2px solid currentColor;
394
+ outline-offset: 2px;
395
+ border-radius: 2px;
396
+ }
397
+
363
398
  /* --- Reduced motion ---------------------------------------------- */
364
399
 
365
400
  @media (prefers-reduced-motion: reduce) {
@@ -326,6 +326,41 @@ body[data-modal-stack-locked] {
326
326
  display: none;
327
327
  }
328
328
 
329
+ /* --- Header slot ------------------------------------------------- */
330
+
331
+ .modal-stack__panel-header {
332
+ display: flex;
333
+ align-items: center;
334
+ justify-content: space-between;
335
+ gap: 0.75rem;
336
+ }
337
+
338
+ .modal-stack__panel-title {
339
+ margin: 0;
340
+ flex: 1;
341
+ min-width: 0;
342
+ }
343
+
344
+ .modal-stack__panel-close {
345
+ font: inherit;
346
+ background: transparent;
347
+ border: 0;
348
+ padding: 0.25rem;
349
+ cursor: pointer;
350
+ color: inherit;
351
+ opacity: 0.55;
352
+ line-height: 1;
353
+ font-size: 1.4em;
354
+ flex-shrink: 0;
355
+ transition: opacity 150ms ease;
356
+ }
357
+ .modal-stack__panel-close:hover { opacity: 1; }
358
+ .modal-stack__panel-close:focus-visible {
359
+ outline: 2px solid currentColor;
360
+ outline-offset: 2px;
361
+ border-radius: 2px;
362
+ }
363
+
329
364
  @media (prefers-reduced-motion: reduce) {
330
365
  #modal-stack-root,
331
366
  #modal-stack-root::backdrop,
@@ -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) {
@@ -207,7 +307,10 @@ function layerPatchFromStreamElement(el) {
207
307
  }
208
308
 
209
309
  function generateLayerId() {
210
- if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
310
+ if (
311
+ typeof crypto !== "undefined" &&
312
+ typeof crypto.randomUUID === "function"
313
+ ) {
211
314
  return crypto.randomUUID();
212
315
  }
213
316
  return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
@@ -71,6 +71,10 @@ export class Orchestrator {
71
71
  return this.state.layers.length;
72
72
  }
73
73
 
74
+ get expectedPopstates() {
75
+ return this.#expectedPopstates;
76
+ }
77
+
74
78
  /**
75
79
  * Push a layer. When `html`/`fragment` are absent, the orchestrator
76
80
  * pre-fetches the URL so `mountLayer` is a sync DOM append (no flash).
@@ -194,6 +198,18 @@ export class Orchestrator {
194
198
  this.#fragmentCache.clear();
195
199
  }
196
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
+
197
213
  // Warm the prefetch cache for `url` without mutating the stack. Safe
198
214
  // to call repeatedly for the same URL (deduped via #inflight) and from
199
215
  // hover/focus handlers; failures are swallowed since this is best-effort.
@@ -217,9 +233,7 @@ export class Orchestrator {
217
233
  // A popstate arriving while we have prefetches in flight means the
218
234
  // user navigated away from any URL we were preloading; drop them.
219
235
  this.#invalidatePrefetch();
220
- return this.#dispatch(
221
- handlePopstate(this.state, { historyState, locationHref }),
222
- );
236
+ return this.#dispatch(handlePopstate(this.state, { historyState, locationHref }));
223
237
  }
224
238
 
225
239
  async #dispatch({ state, commands }, payload = {}) {
@@ -299,12 +299,12 @@ describe("closeAll", () => {
299
299
  const types = runtime._calls.map((c) => c.type);
300
300
  expect(types).toEqual([
301
301
  "closeDialog",
302
+ "clearSnapshot",
302
303
  "unmountAllLayers",
303
304
  "clearFrameCache",
304
305
  "clearFrameCache",
305
306
  "unlockScroll",
306
307
  "historyBack",
307
- "clearSnapshot",
308
308
  ]);
309
309
 
310
310
  runtime._calls.length = 0;
@@ -561,10 +561,10 @@ describe("onPopstate", () => {
561
561
  const types = runtime._calls.map((c) => c.type);
562
562
  expect(types).toEqual([
563
563
  "closeDialog",
564
+ "clearSnapshot",
564
565
  "unmountAllLayers",
565
566
  "clearFrameCache",
566
567
  "unlockScroll",
567
- "clearSnapshot",
568
568
  ]);
569
569
  expect(types).not.toContain("historyBack");
570
570
  });
@@ -1,4 +1,5 @@
1
1
  export const SNAPSHOT_KEY = "modalStackSnapshot";
2
+ export const FRAME_HTML_KEY = "modalStackFrameHtml";
2
3
  export const FRAGMENT_HEADER = "X-Modal-Stack-Request";
3
4
  // Server response header that flags the just-rendered frame as stale —
4
5
  // the runtime will refetch instead of restoring from the in-memory cache
@@ -27,11 +28,17 @@ const LEAVE_TIMEOUT_FALLBACK_MS = 600;
27
28
  * `orchestrator.test.js` for an in-memory fake).
28
29
  */
29
30
  export class BrowserRuntime {
31
+ // Counter: incremented per historyBack call, decremented per Turbo visit
32
+ // cancelled. Guards every popstate that lands on a Turbo-owned entry.
33
+ #suppressTurboVisitCount = 0;
34
+ #suppressTurboVisitTimer = null;
35
+
30
36
  /**
31
37
  * @param {Object} options
32
38
  * @param {HTMLDialogElement} options.dialog
33
39
  * @param {HTMLElement} [options.body]
34
40
  * @param {History} [options.history]
41
+ * @param {Location} [options.location]
35
42
  * @param {typeof fetch} [options.fetcher]
36
43
  * @param {Storage} [options.store]
37
44
  * @param {Document} [options.documentRef]
@@ -40,6 +47,7 @@ export class BrowserRuntime {
40
47
  dialog,
41
48
  body = globalThis.document?.body,
42
49
  history = globalThis.history,
50
+ location = globalThis.location,
43
51
  fetcher = globalThis.fetch?.bind(globalThis),
44
52
  store = globalThis.sessionStorage,
45
53
  documentRef = globalThis.document,
@@ -50,6 +58,7 @@ export class BrowserRuntime {
50
58
  this.dialog = dialog;
51
59
  this.body = body;
52
60
  this.history = history;
61
+ this.location = location;
53
62
  this.fetcher = fetcher;
54
63
  this.store = store;
55
64
  this.document = documentRef;
@@ -57,6 +66,44 @@ export class BrowserRuntime {
57
66
  // Populated by mountFrame on the way forward, drained by unmountFrame on
58
67
  // the way back, purged on layer teardown via clearFrameCache.
59
68
  this._frameCache = new Map();
69
+
70
+ // When our historyBack() navigates back to the original page-load history
71
+ // entry, that entry carries Turbo's restorationIdentifier. Turbo sees it on
72
+ // popstate and starts a restoration visit which replaces the body — including
73
+ // restoring a cached snapshot that had data-modal-stack-locked baked in,
74
+ // leaving the page scroll-locked after the modal closes.
75
+ //
76
+ // We intercept turbo:before-visit and cancel the *one* restoration triggered
77
+ // by our own historyBack. The flag is armed in historyBack and consumed (or
78
+ // timed out) immediately so it cannot suppress a user-initiated navigation.
79
+ this.#suppressTurboVisitCount = 0;
80
+ this._turboVisitGuard = (event) => {
81
+ if (this.#suppressTurboVisitCount <= 0) return;
82
+ this.#suppressTurboVisitCount -= 1;
83
+ if (this.#suppressTurboVisitCount === 0) clearTimeout(this.#suppressTurboVisitTimer);
84
+ event.preventDefault();
85
+ };
86
+ documentRef.addEventListener?.("turbo:before-visit", this._turboVisitGuard);
87
+
88
+ // Turbo caches a page snapshot before navigating away. If the modal is
89
+ // open at cache time, data-modal-stack-locked is baked into the snapshot.
90
+ // When Turbo later restores or morphs from that snapshot, the attribute
91
+ // is re-applied to body, leaving the page scroll-locked even though no
92
+ // modal is open. Stripping the lock before cache keeps snapshots clean.
93
+ this._turboBeforeCache = () => {
94
+ if (!this.body) return;
95
+ delete this.body.dataset.modalStackLocked;
96
+ const root = this.document?.documentElement;
97
+ if (root) root.style.removeProperty(SCROLLBAR_WIDTH_VAR);
98
+ };
99
+ documentRef.addEventListener?.("turbo:before-cache", this._turboBeforeCache);
100
+ }
101
+
102
+ // Called by the Stimulus controller on disconnect so the global listener
103
+ // is cleaned up if the element leaves the DOM (e.g. full page navigation).
104
+ destroy() {
105
+ this.document?.removeEventListener?.("turbo:before-visit", this._turboVisitGuard);
106
+ this.document?.removeEventListener?.("turbo:before-cache", this._turboBeforeCache);
60
107
  }
61
108
 
62
109
  showDialog() {
@@ -130,6 +177,10 @@ export class BrowserRuntime {
130
177
  // animateOut below operates on the wrapper, which we'll throw away.
131
178
  const oldFrame = this.#findFrame(layer, fromFrameIndex);
132
179
  if (oldFrame) {
180
+ // Fire before detach so listeners can still read live form values.
181
+ this.#dispatchFrameEvent("modal_stack:frame-leave", {
182
+ layerId, frameIndex: fromFrameIndex, direction: "forward",
183
+ });
133
184
  const cached = this.document.createDocumentFragment();
134
185
  cached.append(...oldFrame.childNodes);
135
186
  this._frameCache.set(this.#frameKey(layerId, fromFrameIndex), cached);
@@ -140,6 +191,13 @@ export class BrowserRuntime {
140
191
  layer.appendChild(newFrame);
141
192
  this.#applyFrameDepth(layer, toFrameIndex);
142
193
 
194
+ // Persist the incoming frame's HTML so it can be restored across page
195
+ // reloads. Frame 0 is always the layer's initial mount (accessible via
196
+ // GET); frames 1+ may be POST-only wizard steps that 404 on direct fetch.
197
+ if (toFrameIndex > 0) {
198
+ this.#persistFrameHtml(layerId, toFrameIndex, newFrame.innerHTML);
199
+ }
200
+
143
201
  // Old frame is removed synchronously. Entering frames carry
144
202
  // data-transition + data-direction so host CSS can drive the *enter*
145
203
  // animation via @starting-style; the leaving frame would need its own
@@ -147,6 +205,11 @@ export class BrowserRuntime {
147
205
  // which we leave to the host CSS preset.
148
206
  if (oldFrame) oldFrame.remove();
149
207
 
208
+ // Fire after mount so listeners can read/populate the new frame's DOM.
209
+ this.#dispatchFrameEvent("modal_stack:frame-enter", {
210
+ layerId, frameIndex: toFrameIndex, direction: "forward",
211
+ });
212
+
150
213
  // Remove transition attrs once the animation completes so the :has()
151
214
  // rule that clips overflow doesn't persist indefinitely.
152
215
  if (transition) this.#cleanupFrameTransition(newFrame);
@@ -156,6 +219,11 @@ export class BrowserRuntime {
156
219
  const layer = this.#findLayer(layerId);
157
220
  if (!layer) return;
158
221
 
222
+ // Fire before detach so listeners can still read live form values.
223
+ this.#dispatchFrameEvent("modal_stack:frame-leave", {
224
+ layerId, frameIndex: fromFrameIndex, direction: "back",
225
+ });
226
+
159
227
  const cacheKey = this.#frameKey(layerId, toFrameIndex);
160
228
  let restored = stale ? null : this._frameCache.get(cacheKey) ?? null;
161
229
  if (!restored) {
@@ -181,6 +249,11 @@ export class BrowserRuntime {
181
249
  const oldFrame = this.#findFrame(layer, fromFrameIndex);
182
250
  if (oldFrame) oldFrame.remove();
183
251
 
252
+ // Fire after mount so listeners can restore data into the returned frame.
253
+ this.#dispatchFrameEvent("modal_stack:frame-enter", {
254
+ layerId, frameIndex: toFrameIndex, direction: "back",
255
+ });
256
+
184
257
  if (transition) this.#cleanupFrameTransition(newFrame);
185
258
  }
186
259
 
@@ -189,6 +262,7 @@ export class BrowserRuntime {
189
262
  for (const key of [...this._frameCache.keys()]) {
190
263
  if (key.startsWith(prefix)) this._frameCache.delete(key);
191
264
  }
265
+ this.#removeFrameHtmlForLayer(layerId);
192
266
  }
193
267
 
194
268
  async unmountTopLayer() {
@@ -203,15 +277,25 @@ export class BrowserRuntime {
203
277
  await Promise.all(layers.map((l) => animateOut(l, timeout)));
204
278
  }
205
279
 
206
- pushHistory({ url, historyState }) {
207
- this.history.pushState(historyState, "", url);
280
+ pushHistory({ historyState }) {
281
+ this.history.pushState(historyState, "", this.location?.href ?? "");
208
282
  }
209
283
 
210
- replaceHistory({ url, historyState }) {
211
- this.history.replaceState(historyState, "", url);
284
+ replaceHistory({ historyState }) {
285
+ this.history.replaceState(historyState, "", this.location?.href ?? "");
212
286
  }
213
287
 
214
288
  historyBack({ n }) {
289
+ // Arm the guard before calling history.go so turbo:before-visit (which
290
+ // fires synchronously inside Turbo's popstate handler) can cancel the
291
+ // restoration visit. A safety timeout clears the flag if the popstate
292
+ // never triggers a Turbo visit (i.e. we landed on one of our own phantom
293
+ // entries that lack Turbo's restorationIdentifier).
294
+ this.#suppressTurboVisitCount += 1;
295
+ clearTimeout(this.#suppressTurboVisitTimer);
296
+ this.#suppressTurboVisitTimer = setTimeout(() => {
297
+ this.#suppressTurboVisitCount = 0;
298
+ }, 1000);
215
299
  this.history.go(-n);
216
300
  }
217
301
 
@@ -235,6 +319,7 @@ export class BrowserRuntime {
235
319
  if (!this.store) return;
236
320
  try {
237
321
  this.store.removeItem(SNAPSHOT_KEY);
322
+ this.store.removeItem(FRAME_HTML_KEY);
238
323
  } catch {
239
324
  // ignore
240
325
  }
@@ -249,6 +334,60 @@ export class BrowserRuntime {
249
334
  }
250
335
  }
251
336
 
337
+ // Preloads _frameCache from sessionStorage so wizard frames saved across
338
+ // reloads can be restored via pathTo without re-fetching their URLs.
339
+ restoreFrameCacheFromStorage() {
340
+ const map = this.#readFrameHtmlMap();
341
+ for (const [key, html] of Object.entries(map)) {
342
+ this._frameCache.set(key, parseFragment(html, this.document));
343
+ }
344
+ }
345
+
346
+ // Returns the cached fragment for a specific frame (used during restoration).
347
+ getFrameFragment(layerId, frameIndex) {
348
+ return this._frameCache.get(this.#frameKey(layerId, frameIndex)) ?? null;
349
+ }
350
+
351
+ #persistFrameHtml(layerId, frameIndex, html) {
352
+ if (!this.store) return;
353
+ try {
354
+ const map = this.#readFrameHtmlMap();
355
+ map[`${layerId}#${frameIndex}`] = html;
356
+ this.store.setItem(FRAME_HTML_KEY, JSON.stringify(map));
357
+ } catch {
358
+ // sessionStorage full or unavailable — best effort
359
+ }
360
+ }
361
+
362
+ #readFrameHtmlMap() {
363
+ try {
364
+ const raw = this.store?.getItem(FRAME_HTML_KEY);
365
+ return raw ? JSON.parse(raw) : {};
366
+ } catch {
367
+ return {};
368
+ }
369
+ }
370
+
371
+ #removeFrameHtmlForLayer(layerId) {
372
+ if (!this.store) return;
373
+ try {
374
+ const map = this.#readFrameHtmlMap();
375
+ const prefix = `${layerId}#`;
376
+ let changed = false;
377
+ for (const key of Object.keys(map)) {
378
+ if (key.startsWith(prefix)) { delete map[key]; changed = true; }
379
+ }
380
+ if (!changed) return;
381
+ if (Object.keys(map).length === 0) {
382
+ this.store.removeItem(FRAME_HTML_KEY);
383
+ } else {
384
+ this.store.setItem(FRAME_HTML_KEY, JSON.stringify(map));
385
+ }
386
+ } catch {
387
+ // ignore
388
+ }
389
+ }
390
+
252
391
  // Reads --modal-stack-duration from the dialog's computed style and
253
392
  // returns 1.5× that as the safety timeout (in ms). Cached after the
254
393
  // first successful read since the variable is host-CSS-defined and
@@ -293,6 +432,12 @@ export class BrowserRuntime {
293
432
  return `${layerId}#${frameIndex}`;
294
433
  }
295
434
 
435
+ #dispatchFrameEvent(name, detail) {
436
+ this.dialog.dispatchEvent(
437
+ new CustomEvent(name, { bubbles: true, detail }),
438
+ );
439
+ }
440
+
296
441
  #purgeFrameCacheAbove(layerId, frameIndex) {
297
442
  const prefix = `${layerId}#`;
298
443
  for (const key of [...this._frameCache.keys()]) {
@@ -97,20 +97,21 @@ describe("snapshot storage", () => {
97
97
  });
98
98
 
99
99
  describe("history wiring", () => {
100
- test("pushHistory / replaceHistory / historyBack delegate to history", () => {
100
+ test("pushHistory / replaceHistory always use current location.href, ignoring the modal url", () => {
101
101
  const calls = [];
102
102
  const history = {
103
103
  pushState: (s, t, u) => calls.push(["push", s, t, u]),
104
104
  replaceState: (s, t, u) => calls.push(["replace", s, t, u]),
105
105
  go: (n) => calls.push(["go", n]),
106
106
  };
107
- const rt = new BrowserRuntime(noopRuntimeArgs({ history }));
108
- rt.pushHistory({ url: "/a", historyState: { x: 1 } });
109
- rt.replaceHistory({ url: "/b", historyState: { y: 2 } });
107
+ const location = { href: "http://example.com/origin" };
108
+ const rt = new BrowserRuntime(noopRuntimeArgs({ history, location }));
109
+ rt.pushHistory({ url: "/modal-a", historyState: { x: 1 } });
110
+ rt.replaceHistory({ url: "/modal-b", historyState: { y: 2 } });
110
111
  rt.historyBack({ n: 3 });
111
112
  expect(calls).toEqual([
112
- ["push", { x: 1 }, "", "/a"],
113
- ["replace", { y: 2 }, "", "/b"],
113
+ ["push", { x: 1 }, "", "http://example.com/origin"],
114
+ ["replace", { y: 2 }, "", "http://example.com/origin"],
114
115
  ["go", -3],
115
116
  ]);
116
117
  });