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
@@ -2,16 +2,22 @@
2
2
  * @typedef {"modal" | "drawer" | "bottom_sheet" | "confirmation"} Variant
3
3
  * @typedef {"left" | "right" | "top" | "bottom"} DrawerSide
4
4
  * @typedef {"sm" | "md" | "lg" | "xl"} Size
5
+ * @typedef {"slide" | "fade" | "none"} Transition
6
+ *
7
+ * @typedef {Object} Frame
8
+ * @property {string} url Frame URL — written to history when this frame is on top
9
+ * @property {boolean} stale When true, runtime refetches before restoring on back
5
10
  *
6
11
  * @typedef {Object} Layer
7
12
  * @property {string} id Stable layer identifier (used for inertness + DOM lookup)
8
- * @property {string} url Layer URL — also written to history
13
+ * @property {string} url Top frame URL — kept for back-compat with the existing API
9
14
  * @property {Variant} variant
10
15
  * @property {boolean} dismissible
11
16
  * @property {Size|null} size
12
17
  * @property {DrawerSide|null} side Required for drawers; null otherwise
13
18
  * @property {string|null} width Free-form CSS width (e.g. "42rem")
14
19
  * @property {string|null} height
20
+ * @property {readonly Frame[]} frames Path frames; layer.url === frames[last].url
15
21
  *
16
22
  * @typedef {Object} Stack
17
23
  * @property {string} stackId
@@ -29,7 +35,11 @@ export const VARIANTS = Object.freeze([
29
35
  "confirmation",
30
36
  ]);
31
37
 
32
- const SNAPSHOT_VERSION = 1;
38
+ export const TRANSITIONS = Object.freeze(["slide", "fade", "none"]);
39
+
40
+ // v2 introduced `frames` per layer (modal path feature). v1 snapshots are
41
+ // rehydrated by synthesising a single-frame array — see restore().
42
+ const SNAPSHOT_VERSION = 2;
33
43
  const DEFAULT_MAX_AGE_MS = 30 * 60 * 1000;
34
44
  const DRAWER_SIDES = Object.freeze(["left", "right", "top", "bottom"]);
35
45
  const MAX_DEPTH_STRATEGIES = Object.freeze(["raise", "warn", "silent"]);
@@ -66,17 +76,36 @@ function normalizeLayerOptions({ variant, size, side, width, height }) {
66
76
  };
67
77
  }
68
78
 
69
- function freezeLayer({ id, url, variant, dismissible, size, side, width, height }) {
79
+ function freezeFrame({ url, stale = false }) {
80
+ if (typeof url !== "string" || url.length === 0) {
81
+ throw new Error("frame.url required");
82
+ }
83
+ return Object.freeze({ url, stale: !!stale });
84
+ }
85
+
86
+ function freezeFrames(frames) {
87
+ return Object.freeze(frames.map(freezeFrame));
88
+ }
89
+
90
+ function freezeLayer({ id, url, variant, dismissible, size, side, width, height, frames }) {
70
91
  const normalized = normalizeLayerOptions({ variant, size, side, width, height });
92
+ // The layer's url is always the top frame's url; if frames isn't supplied
93
+ // (e.g. fresh push or restoring a v1 snapshot) we synthesize a single frame.
94
+ const framesArray = Array.isArray(frames) && frames.length > 0
95
+ ? frames
96
+ : [{ url, stale: false }];
97
+ const frozenFrames = freezeFrames(framesArray);
98
+ const topUrl = frozenFrames[frozenFrames.length - 1].url;
71
99
  return Object.freeze({
72
100
  id,
73
- url,
101
+ url: topUrl,
74
102
  variant,
75
103
  dismissible: !!dismissible,
76
104
  size: normalized.size,
77
105
  side: normalized.side,
78
106
  width: normalized.width,
79
107
  height: normalized.height,
108
+ frames: frozenFrames,
80
109
  });
81
110
  }
82
111
 
@@ -99,6 +128,20 @@ export function topLayer(state) {
99
128
  return state.layers[state.layers.length - 1] ?? null;
100
129
  }
101
130
 
131
+ function totalFrameCount(layers) {
132
+ let n = 0;
133
+ for (const l of layers) n += l.frames.length;
134
+ return n;
135
+ }
136
+
137
+ function validateTransition(value) {
138
+ if (value == null) return null;
139
+ if (!TRANSITIONS.includes(value)) {
140
+ throw new Error(`unknown transition: ${value}`);
141
+ }
142
+ return value;
143
+ }
144
+
102
145
  /**
103
146
  * Push a new layer on top of the stack.
104
147
  *
@@ -178,7 +221,12 @@ export function push(state, layer, options = {}) {
178
221
  commands.push({
179
222
  type: "pushHistory",
180
223
  url: newLayer.url,
181
- historyState: { stackId: state.stackId, layerId: newLayer.id, depth },
224
+ historyState: {
225
+ stackId: state.stackId,
226
+ layerId: newLayer.id,
227
+ depth,
228
+ frameIndex: 0,
229
+ },
182
230
  });
183
231
  commands.push({ type: "persistSnapshot" });
184
232
 
@@ -186,21 +234,150 @@ export function push(state, layer, options = {}) {
186
234
  }
187
235
 
188
236
  /**
189
- * Pop the top layer. No-op when the stack is empty.
237
+ * Append a frame to the top layer's path. Forward navigation in a wizard
238
+ * that retains a back-history.
239
+ *
240
+ * @param {Stack} state
241
+ * @param {{ url: string, stale?: boolean }} frame
242
+ * @param {{ transition?: Transition|null }} [options]
243
+ * @returns {Transition}
244
+ */
245
+ export function pathTo(state, frame, options = {}) {
246
+ if (state.layers.length === 0) {
247
+ throw new Error("pathTo requires at least one layer");
248
+ }
249
+ if (typeof frame?.url !== "string" || frame.url.length === 0) {
250
+ throw new Error("pathTo requires a frame.url");
251
+ }
252
+ const transition = validateTransition(options.transition ?? null);
253
+
254
+ const top = topLayer(state);
255
+ const previousFrameIndex = top.frames.length - 1;
256
+ const newFrame = freezeFrame({ url: frame.url, stale: !!frame.stale });
257
+ const newFrames = [...top.frames, newFrame];
258
+ const newTop = freezeLayer({
259
+ id: top.id,
260
+ url: newFrame.url,
261
+ variant: top.variant,
262
+ dismissible: top.dismissible,
263
+ size: top.size,
264
+ side: top.side,
265
+ width: top.width,
266
+ height: top.height,
267
+ frames: newFrames,
268
+ });
269
+ const newLayers = Object.freeze([...state.layers.slice(0, -1), newTop]);
270
+ const depth = newLayers.length;
271
+ const newFrameIndex = newFrames.length - 1;
272
+
273
+ return {
274
+ state: { ...state, layers: newLayers },
275
+ commands: [
276
+ {
277
+ type: "mountFrame",
278
+ layerId: newTop.id,
279
+ fromFrameIndex: previousFrameIndex,
280
+ toFrameIndex: newFrameIndex,
281
+ url: newFrame.url,
282
+ stale: newFrame.stale,
283
+ ...(transition ? { transition } : {}),
284
+ },
285
+ {
286
+ type: "pushHistory",
287
+ url: newFrame.url,
288
+ historyState: {
289
+ stackId: state.stackId,
290
+ layerId: newTop.id,
291
+ depth,
292
+ frameIndex: newFrameIndex,
293
+ },
294
+ },
295
+ { type: "persistSnapshot" },
296
+ ],
297
+ };
298
+ }
299
+
300
+ /**
301
+ * Step back through frames in the top layer's path. Clamps at the first
302
+ * frame: the layer is never closed by pathBack — use pop() / closeAll()
303
+ * for that.
304
+ *
305
+ * @param {Stack} state
306
+ * @param {{ steps?: number, transition?: Transition|null }} [options]
307
+ * @returns {Transition}
308
+ */
309
+ export function pathBack(state, options = {}) {
310
+ if (state.layers.length === 0) {
311
+ throw new Error("pathBack requires at least one layer");
312
+ }
313
+ const requestedSteps = options.steps == null ? 1 : Math.floor(options.steps);
314
+ if (!Number.isFinite(requestedSteps) || requestedSteps < 1) {
315
+ throw new Error("pathBack: steps must be a positive integer");
316
+ }
317
+ const transition = validateTransition(options.transition ?? null);
318
+
319
+ const top = topLayer(state);
320
+ const fromFrameIndex = top.frames.length - 1;
321
+ const maxSteps = top.frames.length - 1;
322
+ const effectiveSteps = Math.min(requestedSteps, maxSteps);
323
+ if (effectiveSteps === 0) return { state, commands: [] };
324
+
325
+ const toFrameIndex = fromFrameIndex - effectiveSteps;
326
+ const newFrames = top.frames.slice(0, toFrameIndex + 1);
327
+ const targetFrame = newFrames[newFrames.length - 1];
328
+ const newTop = freezeLayer({
329
+ id: top.id,
330
+ url: targetFrame.url,
331
+ variant: top.variant,
332
+ dismissible: top.dismissible,
333
+ size: top.size,
334
+ side: top.side,
335
+ width: top.width,
336
+ height: top.height,
337
+ frames: newFrames,
338
+ });
339
+ const newLayers = Object.freeze([...state.layers.slice(0, -1), newTop]);
340
+
341
+ return {
342
+ state: { ...state, layers: newLayers },
343
+ commands: [
344
+ {
345
+ type: "unmountFrame",
346
+ layerId: newTop.id,
347
+ fromFrameIndex,
348
+ toFrameIndex,
349
+ url: targetFrame.url,
350
+ stale: targetFrame.stale,
351
+ ...(transition ? { transition } : {}),
352
+ },
353
+ { type: "historyBack", n: effectiveSteps },
354
+ { type: "persistSnapshot" },
355
+ ],
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Pop the top layer (and all of its path frames). No-op when the stack
361
+ * is empty.
190
362
  * @param {Stack} state
191
363
  * @returns {Transition}
192
364
  */
193
365
  export function pop(state) {
194
366
  if (state.layers.length === 0) return { state, commands: [] };
195
367
 
368
+ const popped = topLayer(state);
369
+ const framesToWalkBack = popped.frames.length;
196
370
  const newLayers = Object.freeze(state.layers.slice(0, -1));
197
371
  const newTop = newLayers[newLayers.length - 1] ?? null;
198
372
  const commands = [];
199
373
  if (newTop) {
374
+ // Persist early so a page reload during the animation restores the
375
+ // correct (already-popped) stack rather than the stale one.
376
+ commands.push({ type: "persistSnapshot" });
200
377
  commands.push({ type: "unmountTopLayer" });
201
- commands.push({ type: "historyBack", n: 1 });
378
+ commands.push({ type: "clearFrameCache", layerId: popped.id });
379
+ commands.push({ type: "historyBack", n: framesToWalkBack });
202
380
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
203
- commands.push({ type: "persistSnapshot" });
204
381
  } else {
205
382
  // closeDialog first so the dialog's exit transition (opacity +
206
383
  // backdrop background + display/overlay allow-discrete) starts
@@ -210,10 +387,13 @@ export function pop(state) {
210
387
  // fade kicks in for *another* 220ms — visually the backdrop fades
211
388
  // after the modal is gone.
212
389
  commands.push({ type: "closeDialog" });
390
+ // Clear early so a page reload during the animation does not restore
391
+ // the modal that is already being dismissed.
392
+ commands.push({ type: "clearSnapshot" });
213
393
  commands.push({ type: "unmountTopLayer" });
214
- commands.push({ type: "historyBack", n: 1 });
394
+ commands.push({ type: "clearFrameCache", layerId: popped.id });
395
+ commands.push({ type: "historyBack", n: framesToWalkBack });
215
396
  commands.push({ type: "unlockScroll" });
216
- commands.push({ type: "clearSnapshot" });
217
397
  }
218
398
  return { state: { ...state, layers: newLayers }, commands };
219
399
  }
@@ -234,6 +414,11 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
234
414
  }
235
415
 
236
416
  const top = topLayer(state);
417
+ // replaceTop collapses the top layer's path back to a single frame
418
+ // — the existing path is forgotten. Walk history back one step per
419
+ // dropped frame so the browser's back button doesn't land on stale
420
+ // frame entries.
421
+ const framesToCollapse = top.frames.length - 1;
237
422
  const next = freezeLayer({
238
423
  id: patch.id ?? top.id,
239
424
  url: patch.url ?? top.url,
@@ -243,6 +428,8 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
243
428
  side: patch.side ?? top.side,
244
429
  width: patch.width ?? top.width,
245
430
  height: patch.height ?? top.height,
431
+ // single-frame layer — drop any path that was on the previous layer
432
+ frames: undefined,
246
433
  });
247
434
  const newLayers = Object.freeze([...state.layers.slice(0, -1), next]);
248
435
  const depth = newLayers.length;
@@ -250,28 +437,35 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
250
437
  const historyCmd = {
251
438
  type: historyMode === "push" ? "pushHistory" : "replaceHistory",
252
439
  url: next.url,
253
- historyState: { stackId: state.stackId, layerId: next.id, depth },
440
+ historyState: {
441
+ stackId: state.stackId,
442
+ layerId: next.id,
443
+ depth,
444
+ frameIndex: 0,
445
+ },
254
446
  };
255
447
 
256
- return {
257
- state: { ...state, layers: newLayers },
258
- commands: [
259
- {
260
- type: "morphTopLayer",
261
- layerId: next.id,
262
- url: next.url,
263
- depth,
264
- variant: next.variant,
265
- dismissible: next.dismissible,
266
- ...(next.size ? { size: next.size } : {}),
267
- ...(next.side ? { side: next.side } : {}),
268
- ...(next.width ? { width: next.width } : {}),
269
- ...(next.height ? { height: next.height } : {}),
270
- },
271
- historyCmd,
272
- { type: "persistSnapshot" },
273
- ],
274
- };
448
+ const commands = [];
449
+ if (framesToCollapse > 0) {
450
+ commands.push({ type: "clearFrameCache", layerId: top.id });
451
+ commands.push({ type: "historyBack", n: framesToCollapse });
452
+ }
453
+ commands.push({
454
+ type: "morphTopLayer",
455
+ layerId: next.id,
456
+ url: next.url,
457
+ depth,
458
+ variant: next.variant,
459
+ dismissible: next.dismissible,
460
+ ...(next.size ? { size: next.size } : {}),
461
+ ...(next.side ? { side: next.side } : {}),
462
+ ...(next.width ? { width: next.width } : {}),
463
+ ...(next.height ? { height: next.height } : {}),
464
+ });
465
+ commands.push(historyCmd);
466
+ commands.push({ type: "persistSnapshot" });
467
+
468
+ return { state: { ...state, layers: newLayers }, commands };
275
469
  }
276
470
 
277
471
  /**
@@ -281,24 +475,32 @@ export function replaceTop(state, patch, { historyMode = "replace" } = {}) {
281
475
  */
282
476
  export function closeAll(state) {
283
477
  if (state.layers.length === 0) return { state, commands: [] };
284
- const n = state.layers.length;
478
+ const n = totalFrameCount(state.layers);
479
+ const cacheClears = state.layers.map((l) => ({
480
+ type: "clearFrameCache",
481
+ layerId: l.id,
482
+ }));
285
483
  return {
286
484
  state: { ...state, layers: Object.freeze([]) },
287
485
  // closeDialog first so the dialog's exit transition runs in
288
486
  // parallel with the layers' [data-leaving] transitions.
487
+ // clearSnapshot comes before unmountAllLayers so a reload during
488
+ // the animation does not restore a stack that is already closing.
289
489
  commands: [
290
490
  { type: "closeDialog" },
491
+ { type: "clearSnapshot" },
291
492
  { type: "unmountAllLayers" },
493
+ ...cacheClears,
292
494
  { type: "unlockScroll" },
293
495
  { type: "historyBack", n },
294
- { type: "clearSnapshot" },
295
496
  ],
296
497
  };
297
498
  }
298
499
 
299
500
  /**
300
- * Reduce a browser `popstate` into a transition: pop layers, morph the top,
301
- * or request a rebuild from snapshot for forward navigation.
501
+ * Reduce a browser `popstate` into a transition: pop layers, step back
502
+ * through frames, morph the top, or request a rebuild from snapshot for
503
+ * forward navigation.
302
504
  * @param {Stack} state
303
505
  * @param {{ historyState: any, locationHref: string }} options
304
506
  * @returns {Transition}
@@ -309,14 +511,19 @@ export function handlePopstate(state, { historyState, locationHref }) {
309
511
 
310
512
  if (!isOurs) {
311
513
  if (state.layers.length === 0) return { state, commands: [] };
514
+ const cacheClears = state.layers.map((l) => ({
515
+ type: "clearFrameCache",
516
+ layerId: l.id,
517
+ }));
312
518
  return {
313
519
  state: { ...state, layers: Object.freeze([]) },
314
- // closeDialog first — see closeAll() for rationale.
520
+ // closeDialog and clearSnapshot first — see closeAll() for rationale.
315
521
  commands: [
316
522
  { type: "closeDialog" },
523
+ { type: "clearSnapshot" },
317
524
  { type: "unmountAllLayers" },
525
+ ...cacheClears,
318
526
  { type: "unlockScroll" },
319
- { type: "clearSnapshot" },
320
527
  ],
321
528
  };
322
529
  }
@@ -324,24 +531,36 @@ export function handlePopstate(state, { historyState, locationHref }) {
324
531
  const targetDepth = historyState.depth ?? 0;
325
532
  const currentDepth = state.layers.length;
326
533
  const targetLayerId = historyState.layerId ?? null;
534
+ const targetFrameIndex = historyState.frameIndex ?? 0;
327
535
 
328
536
  if (targetDepth < currentDepth) {
537
+ const droppedLayers = state.layers.slice(targetDepth);
329
538
  const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
330
539
  const newTop = newLayers[newLayers.length - 1] ?? null;
331
540
  const commands = [];
332
- // When popping back to the root via popstate, fire closeDialog
333
- // first so the dialog's exit transition runs alongside the
334
- // sequential unmountTopLayer cascade.
335
- if (!newTop) commands.push({ type: "closeDialog" });
336
- for (let i = 0; i < currentDepth - targetDepth; i++) {
541
+ if (newTop) {
542
+ // Persist before animation so a reload during the transition
543
+ // restores the correct remaining stack.
544
+ commands.push({ type: "persistSnapshot" });
545
+ } else {
546
+ // When popping back to the root via popstate, fire closeDialog
547
+ // first so the dialog's exit transition runs alongside the
548
+ // sequential unmountTopLayer cascade.
549
+ commands.push({ type: "closeDialog" });
550
+ // Clear before animation so a reload during the transition does
551
+ // not restore the stack that is already being dismissed.
552
+ commands.push({ type: "clearSnapshot" });
553
+ }
554
+ for (let i = 0; i < droppedLayers.length; i++) {
337
555
  commands.push({ type: "unmountTopLayer" });
338
556
  }
557
+ for (const dropped of droppedLayers) {
558
+ commands.push({ type: "clearFrameCache", layerId: dropped.id });
559
+ }
339
560
  if (newTop) {
340
561
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
341
- commands.push({ type: "persistSnapshot" });
342
562
  } else {
343
563
  commands.push({ type: "unlockScroll" });
344
- commands.push({ type: "clearSnapshot" });
345
564
  }
346
565
  return { state: { ...state, layers: newLayers }, commands };
347
566
  }
@@ -356,6 +575,55 @@ export function handlePopstate(state, { historyState, locationHref }) {
356
575
  }
357
576
 
358
577
  const top = topLayer(state);
578
+ if (top && targetLayerId && top.id === targetLayerId) {
579
+ const currentFrameIndex = top.frames.length - 1;
580
+ if (targetFrameIndex === currentFrameIndex) {
581
+ return { state, commands: [] };
582
+ }
583
+ if (targetFrameIndex < currentFrameIndex) {
584
+ const newFrames = top.frames.slice(0, targetFrameIndex + 1);
585
+ const targetFrame = newFrames[newFrames.length - 1];
586
+ const updatedTop = freezeLayer({
587
+ id: top.id,
588
+ url: targetFrame.url,
589
+ variant: top.variant,
590
+ dismissible: top.dismissible,
591
+ size: top.size,
592
+ side: top.side,
593
+ width: top.width,
594
+ height: top.height,
595
+ frames: newFrames,
596
+ });
597
+ const newLayers = Object.freeze([
598
+ ...state.layers.slice(0, -1),
599
+ updatedTop,
600
+ ]);
601
+ return {
602
+ state: { ...state, layers: newLayers },
603
+ commands: [
604
+ {
605
+ type: "unmountFrame",
606
+ layerId: top.id,
607
+ fromFrameIndex: currentFrameIndex,
608
+ toFrameIndex: targetFrameIndex,
609
+ url: targetFrame.url,
610
+ stale: targetFrame.stale,
611
+ },
612
+ { type: "persistSnapshot" },
613
+ ],
614
+ };
615
+ }
616
+ // Forward popstate to a frame we no longer track — happens when the
617
+ // user pressed back, then forward, after the path frames were dropped
618
+ // from state. Defer to the controller (snapshot rebuild / fetch).
619
+ return {
620
+ state,
621
+ commands: [
622
+ { type: "rebuildFromSnapshot", targetDepth, targetLayerId },
623
+ ],
624
+ };
625
+ }
626
+
359
627
  if (top && targetLayerId && top.id !== targetLayerId) {
360
628
  const updatedTop = freezeLayer({
361
629
  id: targetLayerId,
@@ -374,6 +642,7 @@ export function handlePopstate(state, { historyState, locationHref }) {
374
642
  return {
375
643
  state: { ...state, layers: newLayers },
376
644
  commands: [
645
+ { type: "clearFrameCache", layerId: top.id },
377
646
  {
378
647
  type: "morphTopLayer",
379
648
  layerId: targetLayerId,
@@ -396,6 +665,11 @@ export function handlePopstate(state, { historyState, locationHref }) {
396
665
 
397
666
  /**
398
667
  * Serialize the stack for sessionStorage. Versioned + timestamped.
668
+ *
669
+ * Frames are serialized as `{ url, stale }` only — the cached HTML lives
670
+ * in the runtime, not the snapshot, so a refresh refetches the top frame
671
+ * and lazily refetches earlier frames if/when the user steps back.
672
+ *
399
673
  * @param {Stack} state
400
674
  * @param {{ now?: () => number }} [options]
401
675
  * @returns {string}
@@ -405,14 +679,32 @@ export function snapshot(state, { now = Date.now } = {}) {
405
679
  v: SNAPSHOT_VERSION,
406
680
  stackId: state.stackId,
407
681
  baseUrl: state.baseUrl,
408
- layers: state.layers,
682
+ layers: state.layers.map(serializeLayer),
409
683
  savedAt: now(),
410
684
  });
411
685
  }
412
686
 
687
+ function serializeLayer(layer) {
688
+ return {
689
+ id: layer.id,
690
+ url: layer.url,
691
+ variant: layer.variant,
692
+ dismissible: layer.dismissible,
693
+ size: layer.size,
694
+ side: layer.side,
695
+ width: layer.width,
696
+ height: layer.height,
697
+ frames: layer.frames.map((f) => ({ url: f.url, stale: f.stale })),
698
+ };
699
+ }
700
+
413
701
  /**
414
702
  * Restore a stack from a serialized snapshot. Returns null on any validation
415
703
  * failure (wrong stackId, expired, malformed JSON, etc.).
704
+ *
705
+ * Accepts both v1 (pre-frames) and v2 snapshots: v1 layers are rehydrated
706
+ * with a synthetic single-frame array so existing tabs survive an upgrade.
707
+ *
416
708
  * @param {string} serialized
417
709
  * @param {{ stackId?: string, maxAgeMs?: number, now?: () => number }} [options]
418
710
  * @returns {Stack|null}
@@ -428,7 +720,7 @@ export function restore(
428
720
  } catch {
429
721
  return null;
430
722
  }
431
- if (parsed?.v !== SNAPSHOT_VERSION) return null;
723
+ if (parsed?.v !== 1 && parsed?.v !== SNAPSHOT_VERSION) return null;
432
724
  if (typeof parsed.stackId !== "string") return null;
433
725
  if (typeof parsed.baseUrl !== "string") return null;
434
726
  if (!Array.isArray(parsed.layers)) return null;
@@ -439,6 +731,12 @@ export function restore(
439
731
  for (const l of parsed.layers) {
440
732
  if (!l || typeof l.id !== "string" || typeof l.url !== "string") return null;
441
733
  if (!VARIANTS.includes(l.variant)) return null;
734
+ if (l.frames !== undefined) {
735
+ if (!Array.isArray(l.frames) || l.frames.length === 0) return null;
736
+ for (const f of l.frames) {
737
+ if (!f || typeof f.url !== "string") return null;
738
+ }
739
+ }
442
740
  }
443
741
 
444
742
  return Object.freeze({