modal_stack 0.4.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6457e6ddbb7a769783e711fd3956cbefc390ec9ada62daa6aa4d5d8a2bb7896
4
- data.tar.gz: bb59d60f0eea1bbfd57506b98eff623ed1cbea3f9cb2002406d32e0a6c22df36
3
+ metadata.gz: 1ef421729ab66f062b0cd608549828f9cec8dd70a8854ad48b94baf2932c70b0
4
+ data.tar.gz: 3de87974331a9634bc32baba6563f20b34da3121594a32627335bfc13b13d8df
5
5
  SHA512:
6
- metadata.gz: 69bdbb2322e3dad7984544f33b823a1ca62eb83993f51a8942a9321bfa7387978df9cc3c4ba3ee56dbfe49dd484a7a5b09f78fe811c7ebc636b8e51dd16e1e2a
7
- data.tar.gz: b7c29d6355f7fb8f6fd15971037798eb22236c05bf19078d9a0cc7c58e080c09c30c0031ce98942b03fa183e8adcc301ac5f36186c77fa86443b63eafd417763
6
+ metadata.gz: 3c217b0849b43aece36f81f333cd78169db098009f4f04cab70ab79f5788949cf4f3c0e99a6aa658ea35e5700d120477e98a8903ead7ca1481f63063068d4abd
7
+ data.tar.gz: 4897264315c5f453abc0302b2f1b436db73ba38f5724c79c568631489d0efdb5b90fa653137ddcdc60326fac0461c3fe0d1dfc20762151c199748454fcb26d33
data/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.2] - 2026-05-15
10
+
11
+ ### Added
12
+ - **URL never changes** — the browser address bar stays on the originating URL when modals open. Each modal/frame still pushes a phantom `history.pushState` entry so the back button closes layers one by one, but the URL itself does not change.
13
+ - **Reload restoration** — the current modal stack (layers + active wizard step) is persisted to `sessionStorage` and fully restored on page reload. Single-step modals restore via a GET re-fetch; POST-only wizard steps are restored from their cached HTML so they never 404.
14
+ - **`modal_stack:frame-leave` / `modal_stack:frame-enter` custom events** — fired on the `<dialog>` (bubbles to `document`) when a path frame transitions in or out. Detail: `{ layerId, frameIndex, direction: "forward" | "back" }`. Use these to save and restore form field values across wizard steps without gem involvement (see README for example Stimulus controller).
15
+ - **`data-turbo-permanent`** on the dialog element by default — prevents Turbo Drive from destroying and recreating the controller on page-restoration renders, eliminating a class of double-listener bugs and mid-animation state loss.
16
+
17
+ ### Fixed
18
+ - **No Turbo page-restoration on modal close** — our `popstate` listener is now registered in capture phase and calls `event.stopImmediatePropagation()` for popstates we triggered ourselves. Turbo's bubble-phase handler never sees them, so no restoration visit, no loading bar, and no body replacement on close.
19
+ - **Scroll lock survives Turbo renders** — `turbo:before-cache` strips `data-modal-stack-locked` from `<body>` before Turbo caches a snapshot; `turbo:render` handler in the controller calls `unlockScroll()` when `depth === 0`, neutralising any cached-snapshot restoration that re-applies the attribute.
20
+ - **Double-pop on concurrent close handlers** — `_onCancel` and `_onBackdropClick` now return early if `!this.element.open`, preventing a second `pop()` call when Stimulus double-connects the controller (which registers two listeners on the same dialog).
21
+ - **Multiple queued `historyBack` calls** — replaced the boolean suppress flag with a counter (`#suppressTurboVisitCount`) so N back-steps each cancel their own Turbo restoration visit independently, with a 1-second safety-valve reset.
22
+ - **`turbo:before-cache` cleanup** — `--modal-stack-scrollbar-width` CSS variable is also stripped before caching so Turbo snapshots are always taken in the unlocked baseline state.
23
+
9
24
  ## [0.4.1] - 2026-05-14
10
25
 
11
26
  ### Added
data/README.md CHANGED
@@ -409,6 +409,8 @@ stale), either pass `stale: true` to `modal_path_to` or set the
409
409
  `X-Modal-Stack-Stale: true` header on the response. The runtime will
410
410
  refetch the URL instead of restoring from cache.
411
411
 
412
+ > **Persisting form data between steps:** form field values are *not* automatically saved — see [`modal_stack:frame-leave` / `modal_stack:frame-enter`](#persisting-form-data-across-wizard-steps) for the recommended pattern.
413
+
412
414
  > ⚠️ **`replaceTop` collapses path frames.** When the top layer has a
413
415
  > path and you call `turbo_stream.modal_replace`, the path is forgotten:
414
416
  > the layer is morphed back to a single frame and the surplus history
@@ -609,12 +611,14 @@ per command name.
609
611
 
610
612
  #### Custom events
611
613
 
612
- The `<dialog>` emits two `CustomEvent`s that bubble to `document`:
614
+ The `<dialog>` emits `CustomEvent`s that bubble to `document`:
613
615
 
614
- | Event | `detail` | Fired when |
615
- | ---------------------- | ------------------------------------------- | ---------- |
616
- | `modal_stack:ready` | `{ stackId }` | The Stimulus controller has connected and the orchestrator is ready. |
617
- | `modal_stack:error` | `{ action, error }` | A Turbo Stream action (`modal_push`/`modal_pop`/`modal_replace`/`modal_close_all`/`modal_path_to`/`modal_path_back`) threw or rejected. The page is not crashed; surface UI feedback in the listener. |
616
+ | Event | `detail` | Fired when |
617
+ | --------------------------- | --------------------------------------------------- | ---------- |
618
+ | `modal_stack:ready` | `{ stackId }` | The Stimulus controller has connected and the orchestrator is ready. |
619
+ | `modal_stack:error` | `{ action, error }` | A Turbo Stream action threw or rejected. The page is not crashed; surface UI feedback in the listener. |
620
+ | `modal_stack:frame-leave` | `{ layerId, frameIndex, direction }` | A path frame is about to be swapped out (user going forward or back). Fired while the leaving frame is still in the DOM — listeners can read current form field values. |
621
+ | `modal_stack:frame-enter` | `{ layerId, frameIndex, direction }` | A path frame has been mounted (user arrived at a step, restored from cache, or restored after page reload). Fired after the new frame is in the DOM — listeners can populate form fields. `direction` is `"forward"` or `"back"`. |
618
622
 
619
623
  ```js
620
624
  document.addEventListener("modal_stack:error", (event) => {
@@ -623,6 +627,72 @@ document.addEventListener("modal_stack:error", (event) => {
623
627
  });
624
628
  ```
625
629
 
630
+ ##### Persisting form data across wizard steps
631
+
632
+ Form field values typed by the user are **not** automatically preserved by modal_stack — that is the responsibility of the host application. Use `modal_stack:frame-leave` and `modal_stack:frame-enter` to save and restore data.
633
+
634
+ Example with a Stimulus controller added to each wizard step view:
635
+
636
+ ```js
637
+ // app/javascript/controllers/wizard_step_controller.js
638
+ import { Controller } from "@hotwired/stimulus"
639
+
640
+ export default class extends Controller {
641
+ static values = { key: String }
642
+
643
+ connect() {
644
+ // Listen on the dialog so events from any nested frame reach us.
645
+ this._leave = (e) => {
646
+ if (this.#isMyFrame(e)) this.#save()
647
+ }
648
+ this._enter = (e) => {
649
+ if (this.#isMyFrame(e)) this.#restore()
650
+ }
651
+ document.addEventListener("modal_stack:frame-leave", this._leave)
652
+ document.addEventListener("modal_stack:frame-enter", this._enter)
653
+ }
654
+
655
+ disconnect() {
656
+ document.removeEventListener("modal_stack:frame-leave", this._leave)
657
+ document.removeEventListener("modal_stack:frame-enter", this._enter)
658
+ }
659
+
660
+ #isMyFrame(event) {
661
+ // The controller's element lives inside the frame — check ancestry.
662
+ return event.target.contains(this.element)
663
+ }
664
+
665
+ #save() {
666
+ const data = {}
667
+ for (const el of this.element.querySelectorAll("input, textarea, select")) {
668
+ if (el.name) data[el.name] = el.value
669
+ }
670
+ sessionStorage.setItem(this.keyValue, JSON.stringify(data))
671
+ }
672
+
673
+ #restore() {
674
+ const raw = sessionStorage.getItem(this.keyValue)
675
+ if (!raw) return
676
+ const data = JSON.parse(raw)
677
+ for (const [name, value] of Object.entries(data)) {
678
+ const el = this.element.querySelector(`[name="${name}"]`)
679
+ if (el) el.value = value
680
+ }
681
+ }
682
+ }
683
+ ```
684
+
685
+ Then attach it to the step partial:
686
+
687
+ ```erb
688
+ <%# app/views/wizard/_step1.html.erb %>
689
+ <div data-controller="wizard-step" data-wizard-step-key-value="wizard-step-1">
690
+ <%# form fields … %>
691
+ </div>
692
+ ```
693
+
694
+ > **Why not auto-persist?** The gem cannot know the shape of your data, applicable validations, or where you want it stored (session, server-side draft, etc.). Keeping this boundary explicit also means you can encrypt, debounce, or sync to a server-side draft without fighting the gem.
695
+
626
696
  #### Scrollbar-width compensation
627
697
 
628
698
  When the first layer is pushed, `BrowserRuntime#lockScroll` measures
@@ -289,18 +289,18 @@ function pop(state) {
289
289
  const newTop = newLayers[newLayers.length - 1] ?? null;
290
290
  const commands = [];
291
291
  if (newTop) {
292
+ commands.push({ type: "persistSnapshot" });
292
293
  commands.push({ type: "unmountTopLayer" });
293
294
  commands.push({ type: "clearFrameCache", layerId: popped.id });
294
295
  commands.push({ type: "historyBack", n: framesToWalkBack });
295
296
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
296
- commands.push({ type: "persistSnapshot" });
297
297
  } else {
298
298
  commands.push({ type: "closeDialog" });
299
+ commands.push({ type: "clearSnapshot" });
299
300
  commands.push({ type: "unmountTopLayer" });
300
301
  commands.push({ type: "clearFrameCache", layerId: popped.id });
301
302
  commands.push({ type: "historyBack", n: framesToWalkBack });
302
303
  commands.push({ type: "unlockScroll" });
303
- commands.push({ type: "clearSnapshot" });
304
304
  }
305
305
  return { state: { ...state, layers: newLayers }, commands };
306
306
  }
@@ -369,11 +369,11 @@ function closeAll(state) {
369
369
  state: { ...state, layers: Object.freeze([]) },
370
370
  commands: [
371
371
  { type: "closeDialog" },
372
+ { type: "clearSnapshot" },
372
373
  { type: "unmountAllLayers" },
373
374
  ...cacheClears,
374
375
  { type: "unlockScroll" },
375
- { type: "historyBack", n },
376
- { type: "clearSnapshot" }
376
+ { type: "historyBack", n }
377
377
  ]
378
378
  };
379
379
  }
@@ -390,10 +390,10 @@ function handlePopstate(state, { historyState, locationHref }) {
390
390
  state: { ...state, layers: Object.freeze([]) },
391
391
  commands: [
392
392
  { type: "closeDialog" },
393
+ { type: "clearSnapshot" },
393
394
  { type: "unmountAllLayers" },
394
395
  ...cacheClears,
395
- { type: "unlockScroll" },
396
- { type: "clearSnapshot" }
396
+ { type: "unlockScroll" }
397
397
  ]
398
398
  };
399
399
  }
@@ -406,8 +406,12 @@ function handlePopstate(state, { historyState, locationHref }) {
406
406
  const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
407
407
  const newTop = newLayers[newLayers.length - 1] ?? null;
408
408
  const commands = [];
409
- if (!newTop)
409
+ if (newTop) {
410
+ commands.push({ type: "persistSnapshot" });
411
+ } else {
410
412
  commands.push({ type: "closeDialog" });
413
+ commands.push({ type: "clearSnapshot" });
414
+ }
411
415
  for (let i = 0;i < droppedLayers.length; i++) {
412
416
  commands.push({ type: "unmountTopLayer" });
413
417
  }
@@ -416,10 +420,8 @@ function handlePopstate(state, { historyState, locationHref }) {
416
420
  }
417
421
  if (newTop) {
418
422
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
419
- commands.push({ type: "persistSnapshot" });
420
423
  } else {
421
424
  commands.push({ type: "unlockScroll" });
422
- commands.push({ type: "clearSnapshot" });
423
425
  }
424
426
  return { state: { ...state, layers: newLayers }, commands };
425
427
  }
@@ -615,6 +617,9 @@ class Orchestrator {
615
617
  get depth() {
616
618
  return this.state.layers.length;
617
619
  }
620
+ get expectedPopstates() {
621
+ return this.#expectedPopstates;
622
+ }
618
623
  async push(layer, { html = null, fragment = null } = {}) {
619
624
  const transition = push(this.state, layer, {
620
625
  maxDepth: this.maxDepth,
@@ -689,6 +694,15 @@ class Orchestrator {
689
694
  this.#inflight.clear();
690
695
  this.#fragmentCache.clear();
691
696
  }
697
+ setFragmentCache(url, fragment) {
698
+ if (!url || !fragment)
699
+ return;
700
+ this.#fragmentCache.set(url, {
701
+ fragment: cloneFragment(fragment),
702
+ stale: false,
703
+ ts: Date.now()
704
+ });
705
+ }
692
706
  prefetch(url) {
693
707
  if (!url || typeof this.runtime.fetchFragment !== "function") {
694
708
  return Promise.resolve(null);
@@ -749,6 +763,7 @@ function supportsAbort() {
749
763
 
750
764
  // app/javascript/modal_stack/runtime.js
751
765
  var SNAPSHOT_KEY = "modalStackSnapshot";
766
+ var FRAME_HTML_KEY = "modalStackFrameHtml";
752
767
  var FRAGMENT_HEADER = "X-Modal-Stack-Request";
753
768
  var STALE_HEADER = "X-Modal-Stack-Stale";
754
769
  var SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
@@ -759,10 +774,13 @@ var LEAVE_TIMEOUT_FLOOR_MS = 300;
759
774
  var LEAVE_TIMEOUT_FALLBACK_MS = 600;
760
775
 
761
776
  class BrowserRuntime {
777
+ #suppressTurboVisitCount = 0;
778
+ #suppressTurboVisitTimer = null;
762
779
  constructor({
763
780
  dialog,
764
781
  body = globalThis.document?.body,
765
782
  history = globalThis.history,
783
+ location = globalThis.location,
766
784
  fetcher = globalThis.fetch?.bind(globalThis),
767
785
  store = globalThis.sessionStorage,
768
786
  documentRef = globalThis.document
@@ -776,10 +794,34 @@ class BrowserRuntime {
776
794
  this.dialog = dialog;
777
795
  this.body = body;
778
796
  this.history = history;
797
+ this.location = location;
779
798
  this.fetcher = fetcher;
780
799
  this.store = store;
781
800
  this.document = documentRef;
782
801
  this._frameCache = new Map;
802
+ this.#suppressTurboVisitCount = 0;
803
+ this._turboVisitGuard = (event) => {
804
+ if (this.#suppressTurboVisitCount <= 0)
805
+ return;
806
+ this.#suppressTurboVisitCount -= 1;
807
+ if (this.#suppressTurboVisitCount === 0)
808
+ clearTimeout(this.#suppressTurboVisitTimer);
809
+ event.preventDefault();
810
+ };
811
+ documentRef.addEventListener?.("turbo:before-visit", this._turboVisitGuard);
812
+ this._turboBeforeCache = () => {
813
+ if (!this.body)
814
+ return;
815
+ delete this.body.dataset.modalStackLocked;
816
+ const root = this.document?.documentElement;
817
+ if (root)
818
+ root.style.removeProperty(SCROLLBAR_WIDTH_VAR);
819
+ };
820
+ documentRef.addEventListener?.("turbo:before-cache", this._turboBeforeCache);
821
+ }
822
+ destroy() {
823
+ this.document?.removeEventListener?.("turbo:before-visit", this._turboVisitGuard);
824
+ this.document?.removeEventListener?.("turbo:before-cache", this._turboBeforeCache);
783
825
  }
784
826
  showDialog() {
785
827
  if (!this.dialog.open)
@@ -844,6 +886,11 @@ class BrowserRuntime {
844
886
  const frag = await this.#resolveFragment({ url, html, fragment });
845
887
  const oldFrame = this.#findFrame(layer, fromFrameIndex);
846
888
  if (oldFrame) {
889
+ this.#dispatchFrameEvent("modal_stack:frame-leave", {
890
+ layerId,
891
+ frameIndex: fromFrameIndex,
892
+ direction: "forward"
893
+ });
847
894
  const cached = this.document.createDocumentFragment();
848
895
  cached.append(...oldFrame.childNodes);
849
896
  this._frameCache.set(this.#frameKey(layerId, fromFrameIndex), cached);
@@ -852,8 +899,16 @@ class BrowserRuntime {
852
899
  newFrame.append(...frag.childNodes);
853
900
  layer.appendChild(newFrame);
854
901
  this.#applyFrameDepth(layer, toFrameIndex);
902
+ if (toFrameIndex > 0) {
903
+ this.#persistFrameHtml(layerId, toFrameIndex, newFrame.innerHTML);
904
+ }
855
905
  if (oldFrame)
856
906
  oldFrame.remove();
907
+ this.#dispatchFrameEvent("modal_stack:frame-enter", {
908
+ layerId,
909
+ frameIndex: toFrameIndex,
910
+ direction: "forward"
911
+ });
857
912
  if (transition)
858
913
  this.#cleanupFrameTransition(newFrame);
859
914
  }
@@ -861,6 +916,11 @@ class BrowserRuntime {
861
916
  const layer = this.#findLayer(layerId);
862
917
  if (!layer)
863
918
  return;
919
+ this.#dispatchFrameEvent("modal_stack:frame-leave", {
920
+ layerId,
921
+ frameIndex: fromFrameIndex,
922
+ direction: "back"
923
+ });
864
924
  const cacheKey = this.#frameKey(layerId, toFrameIndex);
865
925
  let restored = stale ? null : this._frameCache.get(cacheKey) ?? null;
866
926
  if (!restored) {
@@ -878,6 +938,11 @@ class BrowserRuntime {
878
938
  const oldFrame = this.#findFrame(layer, fromFrameIndex);
879
939
  if (oldFrame)
880
940
  oldFrame.remove();
941
+ this.#dispatchFrameEvent("modal_stack:frame-enter", {
942
+ layerId,
943
+ frameIndex: toFrameIndex,
944
+ direction: "back"
945
+ });
881
946
  if (transition)
882
947
  this.#cleanupFrameTransition(newFrame);
883
948
  }
@@ -887,6 +952,7 @@ class BrowserRuntime {
887
952
  if (key.startsWith(prefix))
888
953
  this._frameCache.delete(key);
889
954
  }
955
+ this.#removeFrameHtmlForLayer(layerId);
890
956
  }
891
957
  async unmountTopLayer() {
892
958
  const layer = this.#topLayer();
@@ -899,13 +965,18 @@ class BrowserRuntime {
899
965
  const timeout = this.#leaveTimeoutMs();
900
966
  await Promise.all(layers.map((l) => animateOut(l, timeout)));
901
967
  }
902
- pushHistory({ url, historyState }) {
903
- this.history.pushState(historyState, "", url);
968
+ pushHistory({ historyState }) {
969
+ this.history.pushState(historyState, "", this.location?.href ?? "");
904
970
  }
905
- replaceHistory({ url, historyState }) {
906
- this.history.replaceState(historyState, "", url);
971
+ replaceHistory({ historyState }) {
972
+ this.history.replaceState(historyState, "", this.location?.href ?? "");
907
973
  }
908
974
  historyBack({ n }) {
975
+ this.#suppressTurboVisitCount += 1;
976
+ clearTimeout(this.#suppressTurboVisitTimer);
977
+ this.#suppressTurboVisitTimer = setTimeout(() => {
978
+ this.#suppressTurboVisitCount = 0;
979
+ }, 1000);
909
980
  this.history.go(-n);
910
981
  }
911
982
  rebuildFromSnapshot() {
@@ -923,6 +994,7 @@ class BrowserRuntime {
923
994
  return;
924
995
  try {
925
996
  this.store.removeItem(SNAPSHOT_KEY);
997
+ this.store.removeItem(FRAME_HTML_KEY);
926
998
  } catch {}
927
999
  }
928
1000
  readSnapshot() {
@@ -934,6 +1006,54 @@ class BrowserRuntime {
934
1006
  return null;
935
1007
  }
936
1008
  }
1009
+ restoreFrameCacheFromStorage() {
1010
+ const map = this.#readFrameHtmlMap();
1011
+ for (const [key, html] of Object.entries(map)) {
1012
+ this._frameCache.set(key, parseFragment(html, this.document));
1013
+ }
1014
+ }
1015
+ getFrameFragment(layerId, frameIndex) {
1016
+ return this._frameCache.get(this.#frameKey(layerId, frameIndex)) ?? null;
1017
+ }
1018
+ #persistFrameHtml(layerId, frameIndex, html) {
1019
+ if (!this.store)
1020
+ return;
1021
+ try {
1022
+ const map = this.#readFrameHtmlMap();
1023
+ map[`${layerId}#${frameIndex}`] = html;
1024
+ this.store.setItem(FRAME_HTML_KEY, JSON.stringify(map));
1025
+ } catch {}
1026
+ }
1027
+ #readFrameHtmlMap() {
1028
+ try {
1029
+ const raw = this.store?.getItem(FRAME_HTML_KEY);
1030
+ return raw ? JSON.parse(raw) : {};
1031
+ } catch {
1032
+ return {};
1033
+ }
1034
+ }
1035
+ #removeFrameHtmlForLayer(layerId) {
1036
+ if (!this.store)
1037
+ return;
1038
+ try {
1039
+ const map = this.#readFrameHtmlMap();
1040
+ const prefix = `${layerId}#`;
1041
+ let changed = false;
1042
+ for (const key of Object.keys(map)) {
1043
+ if (key.startsWith(prefix)) {
1044
+ delete map[key];
1045
+ changed = true;
1046
+ }
1047
+ }
1048
+ if (!changed)
1049
+ return;
1050
+ if (Object.keys(map).length === 0) {
1051
+ this.store.removeItem(FRAME_HTML_KEY);
1052
+ } else {
1053
+ this.store.setItem(FRAME_HTML_KEY, JSON.stringify(map));
1054
+ }
1055
+ } catch {}
1056
+ }
937
1057
  #leaveTimeoutMs() {
938
1058
  if (this._cachedLeaveTimeoutMs != null)
939
1059
  return this._cachedLeaveTimeoutMs;
@@ -959,6 +1079,9 @@ class BrowserRuntime {
959
1079
  #frameKey(layerId, frameIndex) {
960
1080
  return `${layerId}#${frameIndex}`;
961
1081
  }
1082
+ #dispatchFrameEvent(name, detail) {
1083
+ this.dialog.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
1084
+ }
962
1085
  #purgeFrameCacheAbove(layerId, frameIndex) {
963
1086
  const prefix = `${layerId}#`;
964
1087
  for (const key of [...this._frameCache.keys()]) {
@@ -1122,26 +1245,38 @@ class ModalStackController extends Controller2 {
1122
1245
  maxDepth: { type: Number, default: 0 },
1123
1246
  maxDepthStrategy: { type: String, default: "warn" }
1124
1247
  };
1248
+ #restoring = false;
1125
1249
  connect() {
1126
- const stackId = this.stackIdValue || generateLayerId();
1127
1250
  const baseUrl = this.baseUrlValue || window.location.href;
1128
1251
  this.runtime = new BrowserRuntime({ dialog: this.element });
1129
- const snapshot2 = this.runtime.readSnapshot();
1252
+ this.runtime.restoreFrameCacheFromStorage();
1253
+ const savedSnapshot = this.runtime.readSnapshot();
1254
+ const snapshotState = savedSnapshot ? restore(savedSnapshot) : null;
1255
+ const stackId = this.stackIdValue || snapshotState?.stackId || generateLayerId();
1130
1256
  this.orchestrator = new Orchestrator({
1131
1257
  runtime: this.runtime,
1132
1258
  stackId,
1133
1259
  baseUrl,
1134
- restoreFrom: snapshot2,
1260
+ restoreFrom: null,
1135
1261
  maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
1136
1262
  maxDepthStrategy: this.maxDepthStrategyValue || "warn"
1137
1263
  });
1138
- this._onPopstate = (event) => this.orchestrator.onPopstate({
1139
- historyState: event.state,
1140
- locationHref: window.location.href
1141
- });
1142
- window.addEventListener("popstate", this._onPopstate);
1264
+ this._onPopstate = (event) => {
1265
+ const isOwn = this.orchestrator.expectedPopstates > 0;
1266
+ this.orchestrator.onPopstate({
1267
+ historyState: event.state,
1268
+ locationHref: window.location.href
1269
+ });
1270
+ if (isOwn)
1271
+ event.stopImmediatePropagation();
1272
+ };
1273
+ window.addEventListener("popstate", this._onPopstate, true);
1143
1274
  this._onCancel = (event) => {
1144
1275
  event.preventDefault();
1276
+ if (!this.element.open)
1277
+ return;
1278
+ if (this.#restoring)
1279
+ return;
1145
1280
  const top = this.#topLayer();
1146
1281
  if (!top || top.dismissible === false)
1147
1282
  return;
@@ -1151,19 +1286,66 @@ class ModalStackController extends Controller2 {
1151
1286
  this._onBackdropClick = (event) => {
1152
1287
  if (event.target !== this.element)
1153
1288
  return;
1289
+ if (!this.element.open)
1290
+ return;
1291
+ if (this.#restoring)
1292
+ return;
1154
1293
  const top = this.#topLayer();
1155
1294
  if (!top || top.dismissible === false)
1156
1295
  return;
1157
1296
  this.orchestrator.pop();
1158
1297
  };
1159
1298
  this.element.addEventListener("click", this._onBackdropClick);
1299
+ this._onTurboRender = () => {
1300
+ if (this.orchestrator.depth === 0)
1301
+ this.runtime.unlockScroll();
1302
+ };
1303
+ document.addEventListener("turbo:render", this._onTurboRender);
1160
1304
  this.#registerStreamActions();
1161
- this.element.dispatchEvent(new CustomEvent("modal_stack:ready", { bubbles: true, detail: { stackId } }));
1305
+ if (snapshotState?.layers?.length > 0) {
1306
+ this.#restoring = true;
1307
+ this.#restoreSnapshot(snapshotState.layers).catch((err) => console.warn("[modal_stack] snapshot restore failed:", err)).finally(() => {
1308
+ this.#restoring = false;
1309
+ });
1310
+ }
1311
+ this.element.dispatchEvent(new CustomEvent("modal_stack:ready", {
1312
+ bubbles: true,
1313
+ detail: { stackId }
1314
+ }));
1315
+ }
1316
+ async#restoreSnapshot(layers) {
1317
+ const baseUrls = layers.map((l) => l.frames?.[0]?.url ?? l.url);
1318
+ const baseFragments = await Promise.all(baseUrls.map((url) => this.orchestrator.prefetch(url).catch(() => null)));
1319
+ for (let i = 0;i < layers.length; i++) {
1320
+ const layer = layers[i];
1321
+ await this.orchestrator.push({
1322
+ id: layer.id,
1323
+ url: baseUrls[i],
1324
+ variant: layer.variant,
1325
+ dismissible: layer.dismissible,
1326
+ size: layer.size,
1327
+ side: layer.side,
1328
+ width: layer.width,
1329
+ height: layer.height
1330
+ }, { fragment: baseFragments[i] });
1331
+ const extraFrames = (layer.frames ?? []).slice(1);
1332
+ for (let fi = 0;fi < extraFrames.length; fi++) {
1333
+ const frame = extraFrames[fi];
1334
+ const frameIndex = fi + 1;
1335
+ const cached = this.runtime.getFrameFragment(layer.id, frameIndex);
1336
+ if (!cached)
1337
+ break;
1338
+ this.orchestrator.setFragmentCache(frame.url, cached.cloneNode(true));
1339
+ await this.orchestrator.pathTo({ url: frame.url, stale: frame.stale }, { fragment: cached.cloneNode(true) });
1340
+ }
1341
+ }
1162
1342
  }
1163
1343
  disconnect() {
1164
- window.removeEventListener("popstate", this._onPopstate);
1344
+ window.removeEventListener("popstate", this._onPopstate, true);
1165
1345
  this.element.removeEventListener("cancel", this._onCancel);
1166
1346
  this.element.removeEventListener("click", this._onBackdropClick);
1347
+ document.removeEventListener("turbo:render", this._onTurboRender);
1348
+ this.runtime.destroy?.();
1167
1349
  }
1168
1350
  push(layer, opts) {
1169
1351
  return this.orchestrator.push(layer, opts);
@@ -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
  });
@@ -371,11 +371,13 @@ export function pop(state) {
371
371
  const newTop = newLayers[newLayers.length - 1] ?? null;
372
372
  const commands = [];
373
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" });
374
377
  commands.push({ type: "unmountTopLayer" });
375
378
  commands.push({ type: "clearFrameCache", layerId: popped.id });
376
379
  commands.push({ type: "historyBack", n: framesToWalkBack });
377
380
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
378
- commands.push({ type: "persistSnapshot" });
379
381
  } else {
380
382
  // closeDialog first so the dialog's exit transition (opacity +
381
383
  // backdrop background + display/overlay allow-discrete) starts
@@ -385,11 +387,13 @@ export function pop(state) {
385
387
  // fade kicks in for *another* 220ms — visually the backdrop fades
386
388
  // after the modal is gone.
387
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" });
388
393
  commands.push({ type: "unmountTopLayer" });
389
394
  commands.push({ type: "clearFrameCache", layerId: popped.id });
390
395
  commands.push({ type: "historyBack", n: framesToWalkBack });
391
396
  commands.push({ type: "unlockScroll" });
392
- commands.push({ type: "clearSnapshot" });
393
397
  }
394
398
  return { state: { ...state, layers: newLayers }, commands };
395
399
  }
@@ -480,13 +484,15 @@ export function closeAll(state) {
480
484
  state: { ...state, layers: Object.freeze([]) },
481
485
  // closeDialog first so the dialog's exit transition runs in
482
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.
483
489
  commands: [
484
490
  { type: "closeDialog" },
491
+ { type: "clearSnapshot" },
485
492
  { type: "unmountAllLayers" },
486
493
  ...cacheClears,
487
494
  { type: "unlockScroll" },
488
495
  { type: "historyBack", n },
489
- { type: "clearSnapshot" },
490
496
  ],
491
497
  };
492
498
  }
@@ -511,13 +517,13 @@ export function handlePopstate(state, { historyState, locationHref }) {
511
517
  }));
512
518
  return {
513
519
  state: { ...state, layers: Object.freeze([]) },
514
- // closeDialog first — see closeAll() for rationale.
520
+ // closeDialog and clearSnapshot first — see closeAll() for rationale.
515
521
  commands: [
516
522
  { type: "closeDialog" },
523
+ { type: "clearSnapshot" },
517
524
  { type: "unmountAllLayers" },
518
525
  ...cacheClears,
519
526
  { type: "unlockScroll" },
520
- { type: "clearSnapshot" },
521
527
  ],
522
528
  };
523
529
  }
@@ -532,10 +538,19 @@ export function handlePopstate(state, { historyState, locationHref }) {
532
538
  const newLayers = Object.freeze(state.layers.slice(0, targetDepth));
533
539
  const newTop = newLayers[newLayers.length - 1] ?? null;
534
540
  const commands = [];
535
- // When popping back to the root via popstate, fire closeDialog
536
- // first so the dialog's exit transition runs alongside the
537
- // sequential unmountTopLayer cascade.
538
- if (!newTop) commands.push({ type: "closeDialog" });
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
+ }
539
554
  for (let i = 0; i < droppedLayers.length; i++) {
540
555
  commands.push({ type: "unmountTopLayer" });
541
556
  }
@@ -544,10 +559,8 @@ export function handlePopstate(state, { historyState, locationHref }) {
544
559
  }
545
560
  if (newTop) {
546
561
  commands.push({ type: "inertLayer", layerId: newTop.id, value: false });
547
- commands.push({ type: "persistSnapshot" });
548
562
  } else {
549
563
  commands.push({ type: "unlockScroll" });
550
- commands.push({ type: "clearSnapshot" });
551
564
  }
552
565
  return { state: { ...state, layers: newLayers }, commands };
553
566
  }
@@ -492,15 +492,15 @@ describe("pop", () => {
492
492
  const first = pushed(freshStack()).state;
493
493
  const { state, commands } = pop(first);
494
494
  expect(state.layers).toEqual([]);
495
- // closeDialog comes first so its exit transition runs in parallel
496
- // with the layer's [data-leaving] transition.
495
+ // closeDialog and clearSnapshot come first so a reload during the
496
+ // exit animation does not restore the modal that is already closing.
497
497
  expect(commands).toEqual([
498
498
  { type: "closeDialog" },
499
+ { type: "clearSnapshot" },
499
500
  { type: "unmountTopLayer" },
500
501
  { type: "clearFrameCache", layerId: "L1" },
501
502
  { type: "historyBack", n: 1 },
502
503
  { type: "unlockScroll" },
503
- { type: "clearSnapshot" },
504
504
  ]);
505
505
  });
506
506
 
@@ -509,12 +509,14 @@ describe("pop", () => {
509
509
  s = push(s, { id: "L2", url: "/clients/new" }).state;
510
510
  const { state, commands } = pop(s);
511
511
  expect(state.layers).toHaveLength(1);
512
+ // persistSnapshot comes first so a reload during the animation
513
+ // restores the correct remaining stack (without the popped layer).
512
514
  expect(commands).toEqual([
515
+ { type: "persistSnapshot" },
513
516
  { type: "unmountTopLayer" },
514
517
  { type: "clearFrameCache", layerId: "L2" },
515
518
  { type: "historyBack", n: 1 },
516
519
  { type: "inertLayer", layerId: "L1", value: false },
517
- { type: "persistSnapshot" },
518
520
  ]);
519
521
  });
520
522
 
@@ -526,11 +528,11 @@ describe("pop", () => {
526
528
  expect(state.layers).toEqual([]);
527
529
  expect(commands).toEqual([
528
530
  { type: "closeDialog" },
531
+ { type: "clearSnapshot" },
529
532
  { type: "unmountTopLayer" },
530
533
  { type: "clearFrameCache", layerId: "L1" },
531
534
  { type: "historyBack", n: 3 },
532
535
  { type: "unlockScroll" },
533
- { type: "clearSnapshot" },
534
536
  ]);
535
537
  });
536
538
  });
@@ -658,13 +660,13 @@ describe("closeAll", () => {
658
660
  expect(state.layers).toEqual([]);
659
661
  expect(commands).toEqual([
660
662
  { type: "closeDialog" },
663
+ { type: "clearSnapshot" },
661
664
  { type: "unmountAllLayers" },
662
665
  { type: "clearFrameCache", layerId: "L1" },
663
666
  { type: "clearFrameCache", layerId: "L2" },
664
667
  { type: "clearFrameCache", layerId: "L3" },
665
668
  { type: "unlockScroll" },
666
669
  { type: "historyBack", n: 3 },
667
- { type: "clearSnapshot" },
668
670
  ]);
669
671
  });
670
672
 
@@ -694,11 +696,11 @@ describe("handlePopstate", () => {
694
696
  expect(state.layers).toEqual([]);
695
697
  expect(commands).toEqual([
696
698
  { type: "closeDialog" },
699
+ { type: "clearSnapshot" },
697
700
  { type: "unmountAllLayers" },
698
701
  { type: "clearFrameCache", layerId: "L1" },
699
702
  { type: "clearFrameCache", layerId: "L2" },
700
703
  { type: "unlockScroll" },
701
- { type: "clearSnapshot" },
702
704
  ]);
703
705
  });
704
706
 
@@ -718,10 +720,10 @@ describe("handlePopstate", () => {
718
720
  });
719
721
  expect(state.layers.map((l) => l.id)).toEqual(["L1"]);
720
722
  expect(commands).toEqual([
723
+ { type: "persistSnapshot" },
721
724
  { type: "unmountTopLayer" },
722
725
  { type: "clearFrameCache", layerId: "L2" },
723
726
  { type: "inertLayer", layerId: "L1", value: false },
724
- { type: "persistSnapshot" },
725
727
  ]);
726
728
  expect(commands).not.toContainEqual({ type: "historyBack", n: 1 });
727
729
  });
@@ -735,12 +737,12 @@ describe("handlePopstate", () => {
735
737
  expect(state.layers).toEqual([]);
736
738
  expect(commands).toEqual([
737
739
  { type: "closeDialog" },
740
+ { type: "clearSnapshot" },
738
741
  { type: "unmountTopLayer" },
739
742
  { type: "unmountTopLayer" },
740
743
  { type: "clearFrameCache", layerId: "L1" },
741
744
  { type: "clearFrameCache", layerId: "L2" },
742
745
  { type: "unlockScroll" },
743
- { type: "clearSnapshot" },
744
746
  ]);
745
747
  });
746
748
 
@@ -39,6 +39,12 @@ module ModalStack
39
39
  data = existing.merge(controller: controllers)
40
40
  data[:modal_stack_max_depth_value] ||= config.max_depth if config.max_depth
41
41
  data[:modal_stack_max_depth_strategy_value] ||= config.max_depth_strategy.to_s
42
+ # Preserve dialog across Turbo Drive page-restores (which fire on
43
+ # popstate when the destination history entry was set by Turbo). Without
44
+ # this, the Stimulus controller disconnects mid-close, the runtime is
45
+ # rebuilt from scratch, and any in-flight state (animations, focused
46
+ # element, click-event propagation) is lost.
47
+ data[:turbo_permanent] = "" unless data.key?(:turbo_permanent)
42
48
  data
43
49
  end
44
50
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ModalStack
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: modal_stack
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Florian Gagnaire