modal_stack 0.2.0 → 0.4.1
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 +59 -0
- data/README.md +136 -52
- data/app/assets/javascripts/modal_stack.js +612 -63
- data/app/assets/stylesheets/modal_stack/bootstrap.css +120 -11
- data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +82 -14
- data/app/assets/stylesheets/modal_stack/tailwind_v4.css +372 -0
- data/app/assets/stylesheets/modal_stack/vanilla.css +128 -11
- data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
- data/app/javascript/modal_stack/controllers/modal_stack_controller.js +54 -0
- data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
- data/app/javascript/modal_stack/install.js +7 -1
- data/app/javascript/modal_stack/orchestrator.js +132 -3
- data/app/javascript/modal_stack/orchestrator.test.js +264 -2
- data/app/javascript/modal_stack/runtime.js +222 -13
- data/app/javascript/modal_stack/runtime.test.js +151 -0
- data/app/javascript/modal_stack/state.js +338 -39
- data/app/javascript/modal_stack/state.test.js +400 -13
- 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/install_generator.rb +18 -4
- data/lib/generators/modal_stack/install/templates/initializer.rb +21 -5
- 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 +43 -17
- data/lib/modal_stack/controller_extensions.rb +8 -1
- 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 +15 -3
- 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 +11 -3
|
@@ -79,6 +79,21 @@ export class ModalStackController extends Controller {
|
|
|
79
79
|
return this.orchestrator.closeAll();
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
prefetch(url) {
|
|
83
|
+
return this.orchestrator.prefetch(url);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Stimulus action — wire up via data-action="click->modal-stack#pathBack"
|
|
87
|
+
// on any button/link inside a modal panel.
|
|
88
|
+
pathBack(event) {
|
|
89
|
+
if (event) {
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
event.stopPropagation();
|
|
92
|
+
}
|
|
93
|
+
const steps = readSteps(event);
|
|
94
|
+
return this.orchestrator.pathBack({ steps });
|
|
95
|
+
}
|
|
96
|
+
|
|
82
97
|
#topLayer() {
|
|
83
98
|
const layers = this.orchestrator.layers;
|
|
84
99
|
return layers[layers.length - 1] ?? null;
|
|
@@ -132,6 +147,21 @@ export class ModalStackController extends Controller {
|
|
|
132
147
|
StreamActions.modal_close_all = guarded("modal_close_all", function (orch) {
|
|
133
148
|
return orch.closeAll();
|
|
134
149
|
});
|
|
150
|
+
|
|
151
|
+
StreamActions.modal_path_to = guarded("modal_path_to", function (orch) {
|
|
152
|
+
return orch.pathTo(frameFromStreamElement(this), {
|
|
153
|
+
fragment: this.templateContent.cloneNode(true),
|
|
154
|
+
transition: this.dataset.transition || null,
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
StreamActions.modal_path_back = guarded("modal_path_back", function (orch) {
|
|
159
|
+
const steps = parsePositiveInt(this.dataset.steps, 1);
|
|
160
|
+
return orch.pathBack({
|
|
161
|
+
steps,
|
|
162
|
+
transition: this.dataset.transition || null,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
135
165
|
}
|
|
136
166
|
}
|
|
137
167
|
|
|
@@ -182,3 +212,27 @@ function generateLayerId() {
|
|
|
182
212
|
}
|
|
183
213
|
return `ms-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
184
214
|
}
|
|
215
|
+
|
|
216
|
+
function frameFromStreamElement(el) {
|
|
217
|
+
return {
|
|
218
|
+
url: el.dataset.url || window.location.href,
|
|
219
|
+
stale: el.dataset.stale === "true" || el.dataset.stale === "1",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function parsePositiveInt(raw, fallback) {
|
|
224
|
+
const n = Number.parseInt(raw, 10);
|
|
225
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Steps for pathBack come from either Stimulus action params
|
|
229
|
+
// (data-modal-stack-steps-param) or a plain data-steps attribute on
|
|
230
|
+
// the action target, e.g. <button data-modal-stack-steps-param="2">.
|
|
231
|
+
function readSteps(event) {
|
|
232
|
+
const params = event?.params;
|
|
233
|
+
if (params && Number.isFinite(params.steps) && params.steps > 0) {
|
|
234
|
+
return params.steps;
|
|
235
|
+
}
|
|
236
|
+
const target = event?.currentTarget ?? event?.target;
|
|
237
|
+
return parsePositiveInt(target?.dataset?.steps, 1);
|
|
238
|
+
}
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
3
|
export class ModalStackLinkController extends Controller {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
connect() {
|
|
5
|
+
if (this.element.dataset.modalStackLinkPrefetch === "false") return;
|
|
6
|
+
this._onIntent = () => this.#warm();
|
|
7
|
+
this.element.addEventListener("pointerenter", this._onIntent);
|
|
8
|
+
this.element.addEventListener("focus", this._onIntent);
|
|
9
|
+
}
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
);
|
|
11
|
+
disconnect() {
|
|
12
|
+
if (!this._onIntent) return;
|
|
13
|
+
this.element.removeEventListener("pointerenter", this._onIntent);
|
|
14
|
+
this.element.removeEventListener("focus", this._onIntent);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
open(event) {
|
|
18
|
+
const controller = this.#stackController();
|
|
12
19
|
if (!controller) return;
|
|
13
20
|
|
|
14
21
|
event.preventDefault();
|
|
@@ -24,6 +31,21 @@ export class ModalStackLinkController extends Controller {
|
|
|
24
31
|
dismissible: ds.modalStackLinkDismissible !== "false",
|
|
25
32
|
});
|
|
26
33
|
}
|
|
34
|
+
|
|
35
|
+
#warm() {
|
|
36
|
+
const controller = this.#stackController();
|
|
37
|
+
if (!controller || typeof controller.prefetch !== "function") return;
|
|
38
|
+
controller.prefetch(this.element.href);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#stackController() {
|
|
42
|
+
const stack = document.querySelector('[data-controller~="modal-stack"]');
|
|
43
|
+
if (!stack) return null;
|
|
44
|
+
return this.application.getControllerForElementAndIdentifier(
|
|
45
|
+
stack,
|
|
46
|
+
"modal-stack",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
27
49
|
}
|
|
28
50
|
|
|
29
51
|
function generateLayerId() {
|
|
@@ -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,
|
|
@@ -9,6 +11,11 @@ import {
|
|
|
9
11
|
snapshot,
|
|
10
12
|
} from "./state.js";
|
|
11
13
|
|
|
14
|
+
// How long a successful prefetch is reused before being refetched. Short
|
|
15
|
+
// enough that stale server-rendered HTML doesn't linger; long enough to
|
|
16
|
+
// absorb back/forward bounces and rapid double-clicks.
|
|
17
|
+
const PREFETCH_TTL_MS = 30_000;
|
|
18
|
+
|
|
12
19
|
/**
|
|
13
20
|
* Owns the current `Stack`, calls the pure reducer, and executes the emitted
|
|
14
21
|
* commands against an injected runtime. The only stateful piece is
|
|
@@ -22,9 +29,16 @@ import {
|
|
|
22
29
|
* @property {string|null} [restoreFrom] Serialized snapshot from sessionStorage
|
|
23
30
|
* @property {number|null} [maxDepth] null disables the cap
|
|
24
31
|
* @property {"raise"|"warn"|"silent"} [maxDepthStrategy]
|
|
32
|
+
* @property {number} [prefetchTtlMs] Override the prefetch cache TTL (testing)
|
|
25
33
|
*/
|
|
26
34
|
export class Orchestrator {
|
|
27
35
|
#expectedPopstates = 0;
|
|
36
|
+
// url → { fragment, ts }. Fragment is the canonical copy; consumers
|
|
37
|
+
// always receive a `cloneNode(true)` so the cached entry stays intact.
|
|
38
|
+
#fragmentCache = new Map();
|
|
39
|
+
// url → { controller, promise }. Lets concurrent prefetches dedupe onto
|
|
40
|
+
// the same in-flight request, and gives `closeAll` a way to cancel them.
|
|
41
|
+
#inflight = new Map();
|
|
28
42
|
|
|
29
43
|
/** @param {OrchestratorOptions} options */
|
|
30
44
|
constructor({
|
|
@@ -34,11 +48,13 @@ export class Orchestrator {
|
|
|
34
48
|
restoreFrom = null,
|
|
35
49
|
maxDepth = null,
|
|
36
50
|
maxDepthStrategy = "warn",
|
|
51
|
+
prefetchTtlMs = PREFETCH_TTL_MS,
|
|
37
52
|
}) {
|
|
38
53
|
if (!runtime) throw new Error("runtime required");
|
|
39
54
|
this.runtime = runtime;
|
|
40
55
|
this.maxDepth = maxDepth;
|
|
41
56
|
this.maxDepthStrategy = maxDepthStrategy;
|
|
57
|
+
this.prefetchTtlMs = prefetchTtlMs;
|
|
42
58
|
this.state = createStack({ stackId, baseUrl });
|
|
43
59
|
|
|
44
60
|
if (restoreFrom) {
|
|
@@ -90,12 +106,106 @@ export class Orchestrator {
|
|
|
90
106
|
return this.#dispatch(replaceTop(this.state, patch, opts), { html, fragment });
|
|
91
107
|
}
|
|
92
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Append a frame to the top layer's path.
|
|
111
|
+
* @param {{ url: string, stale?: boolean }} frame
|
|
112
|
+
* @param {{ html?: string|null, fragment?: DocumentFragment|null, transition?: string|null }} [options]
|
|
113
|
+
*/
|
|
114
|
+
async pathTo(frame, { html = null, fragment = null, transition = null } = {}) {
|
|
115
|
+
let resolvedStale = frame?.stale === true;
|
|
116
|
+
if (fragment == null && html == null && frame?.url) {
|
|
117
|
+
const meta = await this.#prefetchWithMeta(frame.url);
|
|
118
|
+
fragment = meta.fragment;
|
|
119
|
+
// The caller's explicit `stale: true` always wins; if they didn't say,
|
|
120
|
+
// honor the X-Modal-Stack-Stale response header surfaced by the runtime.
|
|
121
|
+
if (frame.stale !== true && meta.stale === true) resolvedStale = true;
|
|
122
|
+
}
|
|
123
|
+
return this.#dispatch(
|
|
124
|
+
pathTo(this.state, { url: frame.url, stale: resolvedStale }, { transition }),
|
|
125
|
+
{ html, fragment },
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Step back through frames in the top layer's path.
|
|
131
|
+
* @param {{ steps?: number, transition?: string|null }} [options]
|
|
132
|
+
*/
|
|
133
|
+
pathBack({ steps = 1, transition = null } = {}) {
|
|
134
|
+
return this.#dispatch(pathBack(this.state, { steps, transition }));
|
|
135
|
+
}
|
|
136
|
+
|
|
93
137
|
async #prefetch(url) {
|
|
94
|
-
|
|
95
|
-
return
|
|
138
|
+
const meta = await this.#prefetchWithMeta(url);
|
|
139
|
+
return meta.fragment;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #prefetchWithMeta(url) {
|
|
143
|
+
if (typeof this.runtime.fetchFragment !== "function") {
|
|
144
|
+
return { fragment: null, stale: false };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const cached = this.#fragmentCache.get(url);
|
|
148
|
+
if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
|
|
149
|
+
return { fragment: cloneFragment(cached.fragment), stale: cached.stale === true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const existing = this.#inflight.get(url);
|
|
153
|
+
if (existing) {
|
|
154
|
+
const entry = await existing.promise;
|
|
155
|
+
return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const controller = supportsAbort() ? new AbortController() : null;
|
|
159
|
+
const fetchPromise = this.runtime
|
|
160
|
+
.fetchFragment(url, controller ? { signal: controller.signal } : undefined)
|
|
161
|
+
.then((result) => {
|
|
162
|
+
// BrowserRuntime returns { fragment, stale }; older test fakes
|
|
163
|
+
// (and prior behavior) returned a bare DocumentFragment — accept
|
|
164
|
+
// both so we don't lock the runtime contract too tightly.
|
|
165
|
+
const fragment = result?.fragment ?? result;
|
|
166
|
+
const stale = result?.stale === true;
|
|
167
|
+
const entry = { fragment, stale, ts: Date.now() };
|
|
168
|
+
this.#fragmentCache.set(url, entry);
|
|
169
|
+
return entry;
|
|
170
|
+
})
|
|
171
|
+
.finally(() => {
|
|
172
|
+
this.#inflight.delete(url);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
this.#inflight.set(url, { controller, promise: fetchPromise });
|
|
176
|
+
const entry = await fetchPromise;
|
|
177
|
+
return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Aborts every in-flight prefetch and forgets any cached fragments.
|
|
181
|
+
// Called when we tear the stack down (closeAll / cross-stack popstate)
|
|
182
|
+
// because the URLs in flight are no longer relevant. In-flight callers
|
|
183
|
+
// see an AbortError; caller code (controllers) already wraps push/pop
|
|
184
|
+
// in try/catch via `guarded()`.
|
|
185
|
+
#invalidatePrefetch() {
|
|
186
|
+
for (const { controller } of this.#inflight.values()) {
|
|
187
|
+
try {
|
|
188
|
+
controller?.abort();
|
|
189
|
+
} catch {
|
|
190
|
+
// ignore — abort is best-effort
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
this.#inflight.clear();
|
|
194
|
+
this.#fragmentCache.clear();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Warm the prefetch cache for `url` without mutating the stack. Safe
|
|
198
|
+
// to call repeatedly for the same URL (deduped via #inflight) and from
|
|
199
|
+
// hover/focus handlers; failures are swallowed since this is best-effort.
|
|
200
|
+
prefetch(url) {
|
|
201
|
+
if (!url || typeof this.runtime.fetchFragment !== "function") {
|
|
202
|
+
return Promise.resolve(null);
|
|
203
|
+
}
|
|
204
|
+
return this.#prefetch(url).catch(() => null);
|
|
96
205
|
}
|
|
97
206
|
|
|
98
207
|
closeAll() {
|
|
208
|
+
this.#invalidatePrefetch();
|
|
99
209
|
return this.#dispatch(closeAll(this.state));
|
|
100
210
|
}
|
|
101
211
|
|
|
@@ -104,6 +214,9 @@ export class Orchestrator {
|
|
|
104
214
|
this.#expectedPopstates -= 1;
|
|
105
215
|
return Promise.resolve();
|
|
106
216
|
}
|
|
217
|
+
// A popstate arriving while we have prefetches in flight means the
|
|
218
|
+
// user navigated away from any URL we were preloading; drop them.
|
|
219
|
+
this.#invalidatePrefetch();
|
|
107
220
|
return this.#dispatch(
|
|
108
221
|
handlePopstate(this.state, { historyState, locationHref }),
|
|
109
222
|
);
|
|
@@ -112,7 +225,11 @@ export class Orchestrator {
|
|
|
112
225
|
async #dispatch({ state, commands }, payload = {}) {
|
|
113
226
|
this.state = state;
|
|
114
227
|
for (const cmd of commands) {
|
|
115
|
-
if (
|
|
228
|
+
if (
|
|
229
|
+
cmd.type === "mountLayer" ||
|
|
230
|
+
cmd.type === "morphTopLayer" ||
|
|
231
|
+
cmd.type === "mountFrame"
|
|
232
|
+
) {
|
|
116
233
|
if (payload.html != null) cmd.html = payload.html;
|
|
117
234
|
if (payload.fragment != null) cmd.fragment = payload.fragment;
|
|
118
235
|
}
|
|
@@ -145,3 +262,15 @@ export class Orchestrator {
|
|
|
145
262
|
await handler.call(this.runtime, cmd);
|
|
146
263
|
}
|
|
147
264
|
}
|
|
265
|
+
|
|
266
|
+
function cloneFragment(fragment) {
|
|
267
|
+
if (!fragment) return fragment;
|
|
268
|
+
if (typeof fragment.cloneNode === "function") {
|
|
269
|
+
return fragment.cloneNode(true);
|
|
270
|
+
}
|
|
271
|
+
return fragment;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function supportsAbort() {
|
|
275
|
+
return typeof globalThis.AbortController === "function";
|
|
276
|
+
}
|
|
@@ -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" });
|
|
@@ -208,8 +298,10 @@ describe("closeAll", () => {
|
|
|
208
298
|
expect(orchestrator.depth).toBe(0);
|
|
209
299
|
const types = runtime._calls.map((c) => c.type);
|
|
210
300
|
expect(types).toEqual([
|
|
211
|
-
"unmountAllLayers",
|
|
212
301
|
"closeDialog",
|
|
302
|
+
"unmountAllLayers",
|
|
303
|
+
"clearFrameCache",
|
|
304
|
+
"clearFrameCache",
|
|
213
305
|
"unlockScroll",
|
|
214
306
|
"historyBack",
|
|
215
307
|
"clearSnapshot",
|
|
@@ -224,6 +316,175 @@ describe("closeAll", () => {
|
|
|
224
316
|
});
|
|
225
317
|
});
|
|
226
318
|
|
|
319
|
+
describe("prefetch cache + abort", () => {
|
|
320
|
+
// Each fakeFragment supports cloneNode so the orchestrator can hand out
|
|
321
|
+
// independent copies without exhausting the cached entry.
|
|
322
|
+
function fakeFragment(label) {
|
|
323
|
+
return {
|
|
324
|
+
label,
|
|
325
|
+
consumed: false,
|
|
326
|
+
cloneNode() {
|
|
327
|
+
return fakeFragment(label);
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function fetchingRuntime({ delayMs = 0, fail = false } = {}) {
|
|
333
|
+
const calls = [];
|
|
334
|
+
const aborts = [];
|
|
335
|
+
const handlerNames = [
|
|
336
|
+
"showDialog",
|
|
337
|
+
"lockScroll",
|
|
338
|
+
"inertLayer",
|
|
339
|
+
"mountLayer",
|
|
340
|
+
"morphTopLayer",
|
|
341
|
+
"unmountTopLayer",
|
|
342
|
+
"unmountAllLayers",
|
|
343
|
+
"closeDialog",
|
|
344
|
+
"unlockScroll",
|
|
345
|
+
"pushHistory",
|
|
346
|
+
"replaceHistory",
|
|
347
|
+
"historyBack",
|
|
348
|
+
"rebuildFromSnapshot",
|
|
349
|
+
"persistSnapshot",
|
|
350
|
+
"clearSnapshot",
|
|
351
|
+
"mountFrame",
|
|
352
|
+
"unmountFrame",
|
|
353
|
+
"clearFrameCache",
|
|
354
|
+
];
|
|
355
|
+
const runtime = { _calls: calls, _fetches: [], _aborts: aborts };
|
|
356
|
+
for (const name of handlerNames) {
|
|
357
|
+
runtime[name] = (cmd) => {
|
|
358
|
+
calls.push({ type: name, ...cmd });
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
runtime.fetchFragment = (url, { signal } = {}) => {
|
|
362
|
+
runtime._fetches.push(url);
|
|
363
|
+
return new Promise((resolve, reject) => {
|
|
364
|
+
const t = setTimeout(() => {
|
|
365
|
+
if (fail) reject(new Error("boom"));
|
|
366
|
+
else resolve(fakeFragment(`frag:${url}`));
|
|
367
|
+
}, delayMs);
|
|
368
|
+
if (signal) {
|
|
369
|
+
signal.addEventListener("abort", () => {
|
|
370
|
+
clearTimeout(t);
|
|
371
|
+
aborts.push(url);
|
|
372
|
+
const err = new Error("aborted");
|
|
373
|
+
err.name = "AbortError";
|
|
374
|
+
reject(err);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
return runtime;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
test("dedupes concurrent prefetches for the same url", async () => {
|
|
383
|
+
const rt = fetchingRuntime({ delayMs: 5 });
|
|
384
|
+
const orch = new Orchestrator({
|
|
385
|
+
runtime: rt,
|
|
386
|
+
stackId: STACK_ID,
|
|
387
|
+
baseUrl: BASE_URL,
|
|
388
|
+
});
|
|
389
|
+
await Promise.all([
|
|
390
|
+
orch.push({ id: "L1", url: "/x" }),
|
|
391
|
+
orch.push({ id: "L2", url: "/x" }),
|
|
392
|
+
]);
|
|
393
|
+
expect(rt._fetches).toEqual(["/x"]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("hits the cache on a second push to the same url", async () => {
|
|
397
|
+
const rt = fetchingRuntime();
|
|
398
|
+
const orch = new Orchestrator({
|
|
399
|
+
runtime: rt,
|
|
400
|
+
stackId: STACK_ID,
|
|
401
|
+
baseUrl: BASE_URL,
|
|
402
|
+
});
|
|
403
|
+
await orch.push({ id: "L1", url: "/x" });
|
|
404
|
+
await orch.pop();
|
|
405
|
+
await orch.push({ id: "L2", url: "/x" });
|
|
406
|
+
expect(rt._fetches).toEqual(["/x"]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("returns a fresh clone per consumer (cache survives consumption)", async () => {
|
|
410
|
+
const rt = fetchingRuntime();
|
|
411
|
+
const orch = new Orchestrator({
|
|
412
|
+
runtime: rt,
|
|
413
|
+
stackId: STACK_ID,
|
|
414
|
+
baseUrl: BASE_URL,
|
|
415
|
+
});
|
|
416
|
+
const seenFragments = [];
|
|
417
|
+
rt.mountLayer = (cmd) => {
|
|
418
|
+
seenFragments.push(cmd.fragment);
|
|
419
|
+
};
|
|
420
|
+
await orch.push({ id: "L1", url: "/x" });
|
|
421
|
+
await orch.pop();
|
|
422
|
+
await orch.push({ id: "L2", url: "/x" });
|
|
423
|
+
expect(seenFragments).toHaveLength(2);
|
|
424
|
+
expect(seenFragments[0]).not.toBe(seenFragments[1]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("TTL expires the cache and triggers a refetch", async () => {
|
|
428
|
+
const rt = fetchingRuntime();
|
|
429
|
+
const orch = new Orchestrator({
|
|
430
|
+
runtime: rt,
|
|
431
|
+
stackId: STACK_ID,
|
|
432
|
+
baseUrl: BASE_URL,
|
|
433
|
+
prefetchTtlMs: 1,
|
|
434
|
+
});
|
|
435
|
+
await orch.push({ id: "L1", url: "/x" });
|
|
436
|
+
await orch.pop();
|
|
437
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
438
|
+
await orch.push({ id: "L2", url: "/x" });
|
|
439
|
+
expect(rt._fetches).toEqual(["/x", "/x"]);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("prefetch warms the cache without dispatching commands", async () => {
|
|
443
|
+
const rt = fetchingRuntime();
|
|
444
|
+
const orch = new Orchestrator({
|
|
445
|
+
runtime: rt,
|
|
446
|
+
stackId: STACK_ID,
|
|
447
|
+
baseUrl: BASE_URL,
|
|
448
|
+
});
|
|
449
|
+
await orch.prefetch("/x");
|
|
450
|
+
expect(rt._fetches).toEqual(["/x"]);
|
|
451
|
+
expect(rt._calls).toEqual([]);
|
|
452
|
+
// Subsequent push consumes the cache, no second fetch.
|
|
453
|
+
await orch.push({ id: "L1", url: "/x" });
|
|
454
|
+
expect(rt._fetches).toEqual(["/x"]);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test("prefetch swallows errors (best-effort)", async () => {
|
|
458
|
+
const rt = fetchingRuntime({ fail: true });
|
|
459
|
+
const orch = new Orchestrator({
|
|
460
|
+
runtime: rt,
|
|
461
|
+
stackId: STACK_ID,
|
|
462
|
+
baseUrl: BASE_URL,
|
|
463
|
+
});
|
|
464
|
+
await expect(orch.prefetch("/boom")).resolves.toBeNull();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("closeAll aborts in-flight prefetches and clears the cache", async () => {
|
|
468
|
+
const rt = fetchingRuntime({ delayMs: 50 });
|
|
469
|
+
const orch = new Orchestrator({
|
|
470
|
+
runtime: rt,
|
|
471
|
+
stackId: STACK_ID,
|
|
472
|
+
baseUrl: BASE_URL,
|
|
473
|
+
});
|
|
474
|
+
// First push completes so the cache has /x.
|
|
475
|
+
await orch.push({ id: "L1", url: "/x" });
|
|
476
|
+
// Second push starts fetching /y and stays in flight.
|
|
477
|
+
const inflight = orch.push({ id: "L2", url: "/y" });
|
|
478
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
479
|
+
await orch.closeAll();
|
|
480
|
+
await expect(inflight).rejects.toThrow(/aborted/);
|
|
481
|
+
expect(rt._aborts).toContain("/y");
|
|
482
|
+
// Cache has been cleared too: re-push of /x must refetch.
|
|
483
|
+
await orch.push({ id: "L3", url: "/x" });
|
|
484
|
+
expect(rt._fetches.filter((u) => u === "/x")).toHaveLength(2);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
227
488
|
describe("onPopstate", () => {
|
|
228
489
|
test("forward navigation requests rebuild from snapshot", async () => {
|
|
229
490
|
await orchestrator.push({ id: "L1", url: "/x" });
|
|
@@ -299,8 +560,9 @@ describe("onPopstate", () => {
|
|
|
299
560
|
|
|
300
561
|
const types = runtime._calls.map((c) => c.type);
|
|
301
562
|
expect(types).toEqual([
|
|
302
|
-
"unmountAllLayers",
|
|
303
563
|
"closeDialog",
|
|
564
|
+
"unmountAllLayers",
|
|
565
|
+
"clearFrameCache",
|
|
304
566
|
"unlockScroll",
|
|
305
567
|
"clearSnapshot",
|
|
306
568
|
]);
|