modal_stack 0.3.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +187 -36
- data/app/assets/javascripts/modal_stack.js +693 -73
- data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
- data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
- data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
- data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +161 -8
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +70 -10
- data/app/javascript/modal_stack/orchestrator.test.js +98 -2
- data/app/javascript/modal_stack/runtime.js +316 -9
- data/app/javascript/modal_stack/runtime.test.js +90 -6
- data/app/javascript/modal_stack/state.js +343 -45
- data/app/javascript/modal_stack/state.test.js +404 -17
- data/app/views/modal_stack/_dialog.html.erb +1 -0
- data/app/views/modal_stack/_panel.html.erb +4 -0
- data/lib/generators/modal_stack/install/templates/initializer.rb +9 -0
- data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
- data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
- data/lib/generators/modal_stack/views/views_generator.rb +50 -0
- data/lib/modal_stack/capybara.rb +21 -0
- data/lib/modal_stack/configuration.rb +37 -16
- data/lib/modal_stack/engine.rb +2 -0
- data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
- data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +7 -1
- data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
- data/lib/modal_stack/turbo_streams_extension.rb +56 -0
- data/lib/modal_stack/version.rb +1 -1
- data/lib/modal_stack.rb +5 -1
- metadata +9 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
import { Orchestrator } from "../orchestrator.js";
|
|
3
3
|
import { BrowserRuntime } from "../runtime.js";
|
|
4
|
+
import { restore } from "../state.js";
|
|
4
5
|
|
|
5
6
|
export class ModalStackController extends Controller {
|
|
6
7
|
static values = {
|
|
@@ -10,33 +11,56 @@ export class ModalStackController extends Controller {
|
|
|
10
11
|
maxDepthStrategy: { type: String, default: "warn" },
|
|
11
12
|
};
|
|
12
13
|
|
|
14
|
+
#restoring = false;
|
|
15
|
+
|
|
13
16
|
connect() {
|
|
14
|
-
const stackId = this.stackIdValue || generateLayerId();
|
|
15
17
|
const baseUrl = this.baseUrlValue || window.location.href;
|
|
16
18
|
|
|
17
19
|
this.runtime = new BrowserRuntime({ dialog: this.element });
|
|
18
|
-
|
|
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) {
|
|
@@ -83,6 +183,17 @@ export class ModalStackController extends Controller {
|
|
|
83
183
|
return this.orchestrator.prefetch(url);
|
|
84
184
|
}
|
|
85
185
|
|
|
186
|
+
// Stimulus action — wire up via data-action="click->modal-stack#pathBack"
|
|
187
|
+
// on any button/link inside a modal panel.
|
|
188
|
+
pathBack(event) {
|
|
189
|
+
if (event) {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
event.stopPropagation();
|
|
192
|
+
}
|
|
193
|
+
const steps = readSteps(event);
|
|
194
|
+
return this.orchestrator.pathBack({ steps });
|
|
195
|
+
}
|
|
196
|
+
|
|
86
197
|
#topLayer() {
|
|
87
198
|
const layers = this.orchestrator.layers;
|
|
88
199
|
return layers[layers.length - 1] ?? null;
|
|
@@ -136,6 +247,21 @@ export class ModalStackController extends Controller {
|
|
|
136
247
|
StreamActions.modal_close_all = guarded("modal_close_all", function (orch) {
|
|
137
248
|
return orch.closeAll();
|
|
138
249
|
});
|
|
250
|
+
|
|
251
|
+
StreamActions.modal_path_to = guarded("modal_path_to", function (orch) {
|
|
252
|
+
return orch.pathTo(frameFromStreamElement(this), {
|
|
253
|
+
fragment: this.templateContent.cloneNode(true),
|
|
254
|
+
transition: this.dataset.transition || null,
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
StreamActions.modal_path_back = guarded("modal_path_back", function (orch) {
|
|
259
|
+
const steps = parsePositiveInt(this.dataset.steps, 1);
|
|
260
|
+
return orch.pathBack({
|
|
261
|
+
steps,
|
|
262
|
+
transition: this.dataset.transition || null,
|
|
263
|
+
});
|
|
264
|
+
});
|
|
139
265
|
}
|
|
140
266
|
}
|
|
141
267
|
|
|
@@ -181,8 +307,35 @@ function layerPatchFromStreamElement(el) {
|
|
|
181
307
|
}
|
|
182
308
|
|
|
183
309
|
function generateLayerId() {
|
|
184
|
-
if (
|
|
310
|
+
if (
|
|
311
|
+
typeof crypto !== "undefined" &&
|
|
312
|
+
typeof crypto.randomUUID === "function"
|
|
313
|
+
) {
|
|
185
314
|
return crypto.randomUUID();
|
|
186
315
|
}
|
|
187
316
|
return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
188
317
|
}
|
|
318
|
+
|
|
319
|
+
function frameFromStreamElement(el) {
|
|
320
|
+
return {
|
|
321
|
+
url: el.dataset.url || window.location.href,
|
|
322
|
+
stale: el.dataset.stale === "true" || el.dataset.stale === "1",
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function parsePositiveInt(raw, fallback) {
|
|
327
|
+
const n = Number.parseInt(raw, 10);
|
|
328
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Steps for pathBack come from either Stimulus action params
|
|
332
|
+
// (data-modal-stack-steps-param) or a plain data-steps attribute on
|
|
333
|
+
// the action target, e.g. <button data-modal-stack-steps-param="2">.
|
|
334
|
+
function readSteps(event) {
|
|
335
|
+
const params = event?.params;
|
|
336
|
+
if (params && Number.isFinite(params.steps) && params.steps > 0) {
|
|
337
|
+
return params.steps;
|
|
338
|
+
}
|
|
339
|
+
const target = event?.currentTarget ?? event?.target;
|
|
340
|
+
return parsePositiveInt(target?.dataset?.steps, 1);
|
|
341
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ModalStackBackLinkController } from "./controllers/modal_stack_back_link_controller.js";
|
|
1
2
|
import { ModalStackController } from "./controllers/modal_stack_controller.js";
|
|
2
3
|
import { ModalStackLinkController } from "./controllers/modal_stack_link_controller.js";
|
|
3
4
|
|
|
@@ -9,7 +10,12 @@ export function install(application) {
|
|
|
9
10
|
}
|
|
10
11
|
application.register("modal-stack", ModalStackController);
|
|
11
12
|
application.register("modal-stack-link", ModalStackLinkController);
|
|
13
|
+
application.register("modal-stack-back-link", ModalStackBackLinkController);
|
|
12
14
|
return application;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
ModalStackBackLinkController,
|
|
19
|
+
ModalStackController,
|
|
20
|
+
ModalStackLinkController,
|
|
21
|
+
};
|
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
closeAll,
|
|
3
3
|
createStack,
|
|
4
4
|
handlePopstate,
|
|
5
|
+
pathBack,
|
|
6
|
+
pathTo,
|
|
5
7
|
pop,
|
|
6
8
|
push,
|
|
7
9
|
replaceTop,
|
|
@@ -69,6 +71,10 @@ export class Orchestrator {
|
|
|
69
71
|
return this.state.layers.length;
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
get expectedPopstates() {
|
|
75
|
+
return this.#expectedPopstates;
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
/**
|
|
73
79
|
* Push a layer. When `html`/`fragment` are absent, the orchestrator
|
|
74
80
|
* pre-fetches the URL so `mountLayer` is a sync DOM append (no flash).
|
|
@@ -104,25 +110,65 @@ export class Orchestrator {
|
|
|
104
110
|
return this.#dispatch(replaceTop(this.state, patch, opts), { html, fragment });
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Append a frame to the top layer's path.
|
|
115
|
+
* @param {{ url: string, stale?: boolean }} frame
|
|
116
|
+
* @param {{ html?: string|null, fragment?: DocumentFragment|null, transition?: string|null }} [options]
|
|
117
|
+
*/
|
|
118
|
+
async pathTo(frame, { html = null, fragment = null, transition = null } = {}) {
|
|
119
|
+
let resolvedStale = frame?.stale === true;
|
|
120
|
+
if (fragment == null && html == null && frame?.url) {
|
|
121
|
+
const meta = await this.#prefetchWithMeta(frame.url);
|
|
122
|
+
fragment = meta.fragment;
|
|
123
|
+
// The caller's explicit `stale: true` always wins; if they didn't say,
|
|
124
|
+
// honor the X-Modal-Stack-Stale response header surfaced by the runtime.
|
|
125
|
+
if (frame.stale !== true && meta.stale === true) resolvedStale = true;
|
|
126
|
+
}
|
|
127
|
+
return this.#dispatch(
|
|
128
|
+
pathTo(this.state, { url: frame.url, stale: resolvedStale }, { transition }),
|
|
129
|
+
{ html, fragment },
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Step back through frames in the top layer's path.
|
|
135
|
+
* @param {{ steps?: number, transition?: string|null }} [options]
|
|
136
|
+
*/
|
|
137
|
+
pathBack({ steps = 1, transition = null } = {}) {
|
|
138
|
+
return this.#dispatch(pathBack(this.state, { steps, transition }));
|
|
139
|
+
}
|
|
140
|
+
|
|
107
141
|
async #prefetch(url) {
|
|
108
|
-
|
|
142
|
+
const meta = await this.#prefetchWithMeta(url);
|
|
143
|
+
return meta.fragment;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async #prefetchWithMeta(url) {
|
|
147
|
+
if (typeof this.runtime.fetchFragment !== "function") {
|
|
148
|
+
return { fragment: null, stale: false };
|
|
149
|
+
}
|
|
109
150
|
|
|
110
151
|
const cached = this.#fragmentCache.get(url);
|
|
111
152
|
if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
|
|
112
|
-
return cloneFragment(cached.fragment);
|
|
153
|
+
return { fragment: cloneFragment(cached.fragment), stale: cached.stale === true };
|
|
113
154
|
}
|
|
114
155
|
|
|
115
156
|
const existing = this.#inflight.get(url);
|
|
116
157
|
if (existing) {
|
|
117
158
|
const entry = await existing.promise;
|
|
118
|
-
return cloneFragment(entry.fragment);
|
|
159
|
+
return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
|
|
119
160
|
}
|
|
120
161
|
|
|
121
162
|
const controller = supportsAbort() ? new AbortController() : null;
|
|
122
163
|
const fetchPromise = this.runtime
|
|
123
164
|
.fetchFragment(url, controller ? { signal: controller.signal } : undefined)
|
|
124
|
-
.then((
|
|
125
|
-
|
|
165
|
+
.then((result) => {
|
|
166
|
+
// BrowserRuntime returns { fragment, stale }; older test fakes
|
|
167
|
+
// (and prior behavior) returned a bare DocumentFragment — accept
|
|
168
|
+
// both so we don't lock the runtime contract too tightly.
|
|
169
|
+
const fragment = result?.fragment ?? result;
|
|
170
|
+
const stale = result?.stale === true;
|
|
171
|
+
const entry = { fragment, stale, ts: Date.now() };
|
|
126
172
|
this.#fragmentCache.set(url, entry);
|
|
127
173
|
return entry;
|
|
128
174
|
})
|
|
@@ -132,7 +178,7 @@ export class Orchestrator {
|
|
|
132
178
|
|
|
133
179
|
this.#inflight.set(url, { controller, promise: fetchPromise });
|
|
134
180
|
const entry = await fetchPromise;
|
|
135
|
-
return cloneFragment(entry.fragment);
|
|
181
|
+
return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
|
|
136
182
|
}
|
|
137
183
|
|
|
138
184
|
// Aborts every in-flight prefetch and forgets any cached fragments.
|
|
@@ -152,6 +198,18 @@ export class Orchestrator {
|
|
|
152
198
|
this.#fragmentCache.clear();
|
|
153
199
|
}
|
|
154
200
|
|
|
201
|
+
// Seed the fragment cache with a known fragment for a URL. Used during
|
|
202
|
+
// snapshot restore to ensure forward re-navigation to a POST-only wizard
|
|
203
|
+
// step reuses the cached HTML rather than attempting a failing GET fetch.
|
|
204
|
+
setFragmentCache(url, fragment) {
|
|
205
|
+
if (!url || !fragment) return;
|
|
206
|
+
this.#fragmentCache.set(url, {
|
|
207
|
+
fragment: cloneFragment(fragment),
|
|
208
|
+
stale: false,
|
|
209
|
+
ts: Date.now(),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
155
213
|
// Warm the prefetch cache for `url` without mutating the stack. Safe
|
|
156
214
|
// to call repeatedly for the same URL (deduped via #inflight) and from
|
|
157
215
|
// hover/focus handlers; failures are swallowed since this is best-effort.
|
|
@@ -175,15 +233,17 @@ export class Orchestrator {
|
|
|
175
233
|
// A popstate arriving while we have prefetches in flight means the
|
|
176
234
|
// user navigated away from any URL we were preloading; drop them.
|
|
177
235
|
this.#invalidatePrefetch();
|
|
178
|
-
return this.#dispatch(
|
|
179
|
-
handlePopstate(this.state, { historyState, locationHref }),
|
|
180
|
-
);
|
|
236
|
+
return this.#dispatch(handlePopstate(this.state, { historyState, locationHref }));
|
|
181
237
|
}
|
|
182
238
|
|
|
183
239
|
async #dispatch({ state, commands }, payload = {}) {
|
|
184
240
|
this.state = state;
|
|
185
241
|
for (const cmd of commands) {
|
|
186
|
-
if (
|
|
242
|
+
if (
|
|
243
|
+
cmd.type === "mountLayer" ||
|
|
244
|
+
cmd.type === "morphTopLayer" ||
|
|
245
|
+
cmd.type === "mountFrame"
|
|
246
|
+
) {
|
|
187
247
|
if (payload.html != null) cmd.html = payload.html;
|
|
188
248
|
if (payload.fragment != null) cmd.fragment = payload.fragment;
|
|
189
249
|
}
|
|
@@ -23,6 +23,9 @@ function recordingRuntime() {
|
|
|
23
23
|
"rebuildFromSnapshot",
|
|
24
24
|
"persistSnapshot",
|
|
25
25
|
"clearSnapshot",
|
|
26
|
+
"mountFrame",
|
|
27
|
+
"unmountFrame",
|
|
28
|
+
"clearFrameCache",
|
|
26
29
|
];
|
|
27
30
|
const runtime = { _calls: calls };
|
|
28
31
|
for (const name of handlerNames) {
|
|
@@ -198,6 +201,93 @@ describe("replaceTop", () => {
|
|
|
198
201
|
});
|
|
199
202
|
});
|
|
200
203
|
|
|
204
|
+
describe("pathTo", () => {
|
|
205
|
+
test("appends a frame, dispatches mountFrame + pushHistory + persistSnapshot", async () => {
|
|
206
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
207
|
+
runtime._calls.length = 0;
|
|
208
|
+
|
|
209
|
+
await orchestrator.pathTo({ url: "/wizard/2" });
|
|
210
|
+
expect(orchestrator.layers[0].frames).toHaveLength(2);
|
|
211
|
+
const types = runtime._calls.map((c) => c.type);
|
|
212
|
+
expect(types).toEqual([
|
|
213
|
+
"mountFrame",
|
|
214
|
+
"pushHistory",
|
|
215
|
+
"persistSnapshot",
|
|
216
|
+
]);
|
|
217
|
+
const mount = runtime._calls.find((c) => c.type === "mountFrame");
|
|
218
|
+
expect(mount).toMatchObject({
|
|
219
|
+
layerId: "L1",
|
|
220
|
+
fromFrameIndex: 0,
|
|
221
|
+
toFrameIndex: 1,
|
|
222
|
+
url: "/wizard/2",
|
|
223
|
+
stale: false,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("explicit stale: true wins over the response header", async () => {
|
|
228
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
229
|
+
runtime._calls.length = 0;
|
|
230
|
+
|
|
231
|
+
await orchestrator.pathTo({ url: "/wizard/2", stale: true });
|
|
232
|
+
const mount = runtime._calls.find((c) => c.type === "mountFrame");
|
|
233
|
+
expect(mount.stale).toBe(true);
|
|
234
|
+
expect(orchestrator.layers[0].frames[1].stale).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("threads transition through to the runtime", async () => {
|
|
238
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
239
|
+
runtime._calls.length = 0;
|
|
240
|
+
await orchestrator.pathTo({ url: "/wizard/2" }, { transition: "fade" });
|
|
241
|
+
expect(runtime._calls.find((c) => c.type === "mountFrame")).toMatchObject({
|
|
242
|
+
transition: "fade",
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("pathBack", () => {
|
|
248
|
+
test("on a single-frame layer is a noop (does not close the layer)", async () => {
|
|
249
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
250
|
+
runtime._calls.length = 0;
|
|
251
|
+
await orchestrator.pathBack();
|
|
252
|
+
expect(orchestrator.layers[0].frames).toHaveLength(1);
|
|
253
|
+
expect(runtime._calls).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("steps back through frames and walks history", async () => {
|
|
257
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
258
|
+
await orchestrator.pathTo({ url: "/wizard/2" });
|
|
259
|
+
await orchestrator.pathTo({ url: "/wizard/3" });
|
|
260
|
+
runtime._calls.length = 0;
|
|
261
|
+
|
|
262
|
+
await orchestrator.pathBack({ steps: 2 });
|
|
263
|
+
expect(orchestrator.layers[0].frames).toHaveLength(1);
|
|
264
|
+
const types = runtime._calls.map((c) => c.type);
|
|
265
|
+
expect(types).toEqual([
|
|
266
|
+
"unmountFrame",
|
|
267
|
+
"historyBack",
|
|
268
|
+
"persistSnapshot",
|
|
269
|
+
]);
|
|
270
|
+
expect(runtime._calls.find((c) => c.type === "historyBack")).toEqual({
|
|
271
|
+
type: "historyBack",
|
|
272
|
+
n: 2,
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("guards the popstate that history.go triggers", async () => {
|
|
277
|
+
await orchestrator.push({ id: "L1", url: "/wizard/1" });
|
|
278
|
+
await orchestrator.pathTo({ url: "/wizard/2" });
|
|
279
|
+
runtime._calls.length = 0;
|
|
280
|
+
|
|
281
|
+
await orchestrator.pathBack();
|
|
282
|
+
runtime._calls.length = 0;
|
|
283
|
+
await orchestrator.onPopstate({
|
|
284
|
+
historyState: { stackId: STACK_ID, layerId: "L1", depth: 1, frameIndex: 0 },
|
|
285
|
+
locationHref: "/wizard/1",
|
|
286
|
+
});
|
|
287
|
+
expect(runtime._calls).toEqual([]);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
201
291
|
describe("closeAll", () => {
|
|
202
292
|
test("clears layers and increments guard once for the historyBack call", async () => {
|
|
203
293
|
await orchestrator.push({ id: "L1", url: "/x" });
|
|
@@ -209,10 +299,12 @@ describe("closeAll", () => {
|
|
|
209
299
|
const types = runtime._calls.map((c) => c.type);
|
|
210
300
|
expect(types).toEqual([
|
|
211
301
|
"closeDialog",
|
|
302
|
+
"clearSnapshot",
|
|
212
303
|
"unmountAllLayers",
|
|
304
|
+
"clearFrameCache",
|
|
305
|
+
"clearFrameCache",
|
|
213
306
|
"unlockScroll",
|
|
214
307
|
"historyBack",
|
|
215
|
-
"clearSnapshot",
|
|
216
308
|
]);
|
|
217
309
|
|
|
218
310
|
runtime._calls.length = 0;
|
|
@@ -256,6 +348,9 @@ describe("prefetch cache + abort", () => {
|
|
|
256
348
|
"rebuildFromSnapshot",
|
|
257
349
|
"persistSnapshot",
|
|
258
350
|
"clearSnapshot",
|
|
351
|
+
"mountFrame",
|
|
352
|
+
"unmountFrame",
|
|
353
|
+
"clearFrameCache",
|
|
259
354
|
];
|
|
260
355
|
const runtime = { _calls: calls, _fetches: [], _aborts: aborts };
|
|
261
356
|
for (const name of handlerNames) {
|
|
@@ -466,9 +561,10 @@ describe("onPopstate", () => {
|
|
|
466
561
|
const types = runtime._calls.map((c) => c.type);
|
|
467
562
|
expect(types).toEqual([
|
|
468
563
|
"closeDialog",
|
|
564
|
+
"clearSnapshot",
|
|
469
565
|
"unmountAllLayers",
|
|
566
|
+
"clearFrameCache",
|
|
470
567
|
"unlockScroll",
|
|
471
|
-
"clearSnapshot",
|
|
472
568
|
]);
|
|
473
569
|
expect(types).not.toContain("historyBack");
|
|
474
570
|
});
|