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 +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +75 -5
- data/app/assets/javascripts/modal_stack.js +205 -23
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +111 -8
- data/app/javascript/modal_stack/orchestrator.js +17 -3
- data/app/javascript/modal_stack/orchestrator.test.js +2 -2
- data/app/javascript/modal_stack/runtime.js +149 -4
- data/app/javascript/modal_stack/runtime.test.js +7 -6
- data/app/javascript/modal_stack/state.js +24 -11
- data/app/javascript/modal_stack/state.test.js +11 -9
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +6 -0
- data/lib/modal_stack/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ef421729ab66f062b0cd608549828f9cec8dd70a8854ad48b94baf2932c70b0
|
|
4
|
+
data.tar.gz: 3de87974331a9634bc32baba6563f20b34da3121594a32627335bfc13b13d8df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
614
|
+
The `<dialog>` emits `CustomEvent`s that bubble to `document`:
|
|
613
615
|
|
|
614
|
-
| Event
|
|
615
|
-
|
|
|
616
|
-
| `modal_stack:ready`
|
|
617
|
-
| `modal_stack:error`
|
|
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 (
|
|
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({
|
|
903
|
-
this.history.pushState(historyState, "",
|
|
968
|
+
pushHistory({ historyState }) {
|
|
969
|
+
this.history.pushState(historyState, "", this.location?.href ?? "");
|
|
904
970
|
}
|
|
905
|
-
replaceHistory({
|
|
906
|
-
this.history.replaceState(historyState, "",
|
|
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
|
-
|
|
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:
|
|
1260
|
+
restoreFrom: null,
|
|
1135
1261
|
maxDepth: this.maxDepthValue > 0 ? this.maxDepthValue : null,
|
|
1136
1262
|
maxDepthStrategy: this.maxDepthStrategyValue || "warn"
|
|
1137
1263
|
});
|
|
1138
|
-
this._onPopstate = (event) =>
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", {
|
|
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 (
|
|
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({
|
|
207
|
-
this.history.pushState(historyState, "",
|
|
280
|
+
pushHistory({ historyState }) {
|
|
281
|
+
this.history.pushState(historyState, "", this.location?.href ?? "");
|
|
208
282
|
}
|
|
209
283
|
|
|
210
|
-
replaceHistory({
|
|
211
|
-
this.history.replaceState(historyState, "",
|
|
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
|
|
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
|
|
108
|
-
rt
|
|
109
|
-
rt.
|
|
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 }, "", "/
|
|
113
|
-
["replace", { y: 2 }, "", "/
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
|
496
|
-
//
|
|
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
|
|
data/lib/modal_stack/version.rb
CHANGED