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
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
export const SNAPSHOT_KEY = "modalStackSnapshot";
|
|
2
2
|
export const FRAGMENT_HEADER = "X-Modal-Stack-Request";
|
|
3
|
+
// Server response header that flags the just-rendered frame as stale —
|
|
4
|
+
// the runtime will refetch instead of restoring from the in-memory cache
|
|
5
|
+
// when the user steps back to it.
|
|
6
|
+
export const STALE_HEADER = "X-Modal-Stack-Stale";
|
|
3
7
|
export const SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
|
|
4
8
|
|
|
5
9
|
const LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
|
|
10
|
+
const FRAME_SELECTOR = "[data-modal-stack-frame]";
|
|
11
|
+
// CSS variable host stylesheets set to declare their leave-transition
|
|
12
|
+
// duration (e.g. "220ms"). When present, the runtime sizes its safety
|
|
13
|
+
// timeout from this value; otherwise it falls back to a conservative cap.
|
|
14
|
+
const DURATION_CSS_VAR = "--modal-stack-duration";
|
|
15
|
+
// Floor for the safety timeout — even very short CSS transitions need
|
|
16
|
+
// enough headroom for transitionend to fire on slow devices.
|
|
17
|
+
const LEAVE_TIMEOUT_FLOOR_MS = 300;
|
|
18
|
+
// Used when no CSS variable is exposed (host CSS missing, JSDOM tests).
|
|
19
|
+
const LEAVE_TIMEOUT_FALLBACK_MS = 600;
|
|
9
20
|
|
|
10
21
|
/**
|
|
11
22
|
* The only file that touches `<dialog>`, `history`, `fetch`, and
|
|
@@ -42,6 +53,10 @@ export class BrowserRuntime {
|
|
|
42
53
|
this.fetcher = fetcher;
|
|
43
54
|
this.store = store;
|
|
44
55
|
this.document = documentRef;
|
|
56
|
+
// Cached DocumentFragments for path frames, keyed by `${layerId}#${frameIndex}`.
|
|
57
|
+
// Populated by mountFrame on the way forward, drained by unmountFrame on
|
|
58
|
+
// the way back, purged on layer teardown via clearFrameCache.
|
|
59
|
+
this._frameCache = new Map();
|
|
45
60
|
}
|
|
46
61
|
|
|
47
62
|
showDialog() {
|
|
@@ -87,7 +102,10 @@ export class BrowserRuntime {
|
|
|
87
102
|
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
88
103
|
const layer = this.document.createElement("div");
|
|
89
104
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
90
|
-
layer
|
|
105
|
+
this.#applyFrameDepth(layer, 0);
|
|
106
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
107
|
+
wrapper.append(...frag.childNodes);
|
|
108
|
+
layer.appendChild(wrapper);
|
|
91
109
|
this.dialog.appendChild(layer);
|
|
92
110
|
}
|
|
93
111
|
|
|
@@ -96,18 +114,93 @@ export class BrowserRuntime {
|
|
|
96
114
|
const layer = this.#topLayer();
|
|
97
115
|
if (!layer) return;
|
|
98
116
|
this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
|
|
99
|
-
layer
|
|
117
|
+
this.#applyFrameDepth(layer, 0);
|
|
118
|
+
const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
|
|
119
|
+
wrapper.append(...frag.childNodes);
|
|
120
|
+
layer.replaceChildren(wrapper);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async mountFrame({ layerId, fromFrameIndex, toFrameIndex, url, html, fragment, transition }) {
|
|
124
|
+
const layer = this.#findLayer(layerId);
|
|
125
|
+
if (!layer) return;
|
|
126
|
+
const frag = await this.#resolveFragment({ url, html, fragment });
|
|
127
|
+
|
|
128
|
+
// Snapshot the outgoing frame's children for back navigation. We cache
|
|
129
|
+
// the original nodes (not clones) and detach them from the DOM — the
|
|
130
|
+
// animateOut below operates on the wrapper, which we'll throw away.
|
|
131
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
132
|
+
if (oldFrame) {
|
|
133
|
+
const cached = this.document.createDocumentFragment();
|
|
134
|
+
cached.append(...oldFrame.childNodes);
|
|
135
|
+
this._frameCache.set(this.#frameKey(layerId, fromFrameIndex), cached);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "forward" });
|
|
139
|
+
newFrame.append(...frag.childNodes);
|
|
140
|
+
layer.appendChild(newFrame);
|
|
141
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
142
|
+
|
|
143
|
+
// Old frame is removed synchronously. Entering frames carry
|
|
144
|
+
// data-transition + data-direction so host CSS can drive the *enter*
|
|
145
|
+
// animation via @starting-style; the leaving frame would need its own
|
|
146
|
+
// overlapping layout (e.g. position: absolute) to also animate out,
|
|
147
|
+
// which we leave to the host CSS preset.
|
|
148
|
+
if (oldFrame) oldFrame.remove();
|
|
149
|
+
|
|
150
|
+
// Remove transition attrs once the animation completes so the :has()
|
|
151
|
+
// rule that clips overflow doesn't persist indefinitely.
|
|
152
|
+
if (transition) this.#cleanupFrameTransition(newFrame);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async unmountFrame({ layerId, fromFrameIndex, toFrameIndex, url, stale, transition }) {
|
|
156
|
+
const layer = this.#findLayer(layerId);
|
|
157
|
+
if (!layer) return;
|
|
158
|
+
|
|
159
|
+
const cacheKey = this.#frameKey(layerId, toFrameIndex);
|
|
160
|
+
let restored = stale ? null : this._frameCache.get(cacheKey) ?? null;
|
|
161
|
+
if (!restored) {
|
|
162
|
+
const result = await this.fetchFragment(url);
|
|
163
|
+
restored = result.fragment;
|
|
164
|
+
this._frameCache.set(cacheKey, cloneFragment(restored, this.document));
|
|
165
|
+
} else {
|
|
166
|
+
// Clone so the cache entry remains usable if we step back here again
|
|
167
|
+
// after another forward.
|
|
168
|
+
restored = cloneFragment(restored, this.document);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "back" });
|
|
172
|
+
newFrame.append(...restored.childNodes);
|
|
173
|
+
layer.appendChild(newFrame);
|
|
174
|
+
this.#applyFrameDepth(layer, toFrameIndex);
|
|
175
|
+
|
|
176
|
+
// Drop cache entries for frames that are now gone (anything past the
|
|
177
|
+
// restored index — we only keep entries for frames still tracked in
|
|
178
|
+
// state).
|
|
179
|
+
this.#purgeFrameCacheAbove(layerId, toFrameIndex);
|
|
180
|
+
|
|
181
|
+
const oldFrame = this.#findFrame(layer, fromFrameIndex);
|
|
182
|
+
if (oldFrame) oldFrame.remove();
|
|
183
|
+
|
|
184
|
+
if (transition) this.#cleanupFrameTransition(newFrame);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
clearFrameCache({ layerId }) {
|
|
188
|
+
const prefix = `${layerId}#`;
|
|
189
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
190
|
+
if (key.startsWith(prefix)) this._frameCache.delete(key);
|
|
191
|
+
}
|
|
100
192
|
}
|
|
101
193
|
|
|
102
194
|
async unmountTopLayer() {
|
|
103
195
|
const layer = this.#topLayer();
|
|
104
196
|
if (!layer) return;
|
|
105
|
-
await animateOut(layer);
|
|
197
|
+
await animateOut(layer, this.#leaveTimeoutMs());
|
|
106
198
|
}
|
|
107
199
|
|
|
108
200
|
async unmountAllLayers() {
|
|
109
201
|
const layers = [...this.dialog.querySelectorAll(LAYER_SELECTOR)];
|
|
110
|
-
|
|
202
|
+
const timeout = this.#leaveTimeoutMs();
|
|
203
|
+
await Promise.all(layers.map((l) => animateOut(l, timeout)));
|
|
111
204
|
}
|
|
112
205
|
|
|
113
206
|
pushHistory({ url, historyState }) {
|
|
@@ -156,12 +249,91 @@ export class BrowserRuntime {
|
|
|
156
249
|
}
|
|
157
250
|
}
|
|
158
251
|
|
|
252
|
+
// Reads --modal-stack-duration from the dialog's computed style and
|
|
253
|
+
// returns 1.5× that as the safety timeout (in ms). Cached after the
|
|
254
|
+
// first successful read since the variable is host-CSS-defined and
|
|
255
|
+
// shouldn't change at runtime. Returns LEAVE_TIMEOUT_FALLBACK_MS when
|
|
256
|
+
// getComputedStyle is unavailable (tests) or the variable is missing.
|
|
257
|
+
#leaveTimeoutMs() {
|
|
258
|
+
if (this._cachedLeaveTimeoutMs != null) return this._cachedLeaveTimeoutMs;
|
|
259
|
+
|
|
260
|
+
const get = globalThis.getComputedStyle;
|
|
261
|
+
if (typeof get !== "function" || !this.dialog?.ownerDocument) {
|
|
262
|
+
return LEAVE_TIMEOUT_FALLBACK_MS;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let parsed = NaN;
|
|
266
|
+
try {
|
|
267
|
+
const raw = get(this.dialog).getPropertyValue(DURATION_CSS_VAR);
|
|
268
|
+
parsed = parseDurationMs(raw);
|
|
269
|
+
} catch {
|
|
270
|
+
// getComputedStyle can throw in detached/foreign documents.
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const ms = Number.isFinite(parsed)
|
|
274
|
+
? Math.max(Math.ceil(parsed * 1.5), LEAVE_TIMEOUT_FLOOR_MS)
|
|
275
|
+
: LEAVE_TIMEOUT_FALLBACK_MS;
|
|
276
|
+
this._cachedLeaveTimeoutMs = ms;
|
|
277
|
+
return ms;
|
|
278
|
+
}
|
|
279
|
+
|
|
159
280
|
#findLayer(layerId) {
|
|
160
281
|
return this.dialog.querySelector(
|
|
161
282
|
`${LAYER_SELECTOR}[data-layer-id="${escapeAttr(layerId)}"]`,
|
|
162
283
|
);
|
|
163
284
|
}
|
|
164
285
|
|
|
286
|
+
#findFrame(layer, frameIndex) {
|
|
287
|
+
return layer.querySelector(
|
|
288
|
+
`${FRAME_SELECTOR}[data-frame-index="${escapeAttr(String(frameIndex))}"]`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#frameKey(layerId, frameIndex) {
|
|
293
|
+
return `${layerId}#${frameIndex}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#purgeFrameCacheAbove(layerId, frameIndex) {
|
|
297
|
+
const prefix = `${layerId}#`;
|
|
298
|
+
for (const key of [...this._frameCache.keys()]) {
|
|
299
|
+
if (!key.startsWith(prefix)) continue;
|
|
300
|
+
const idx = Number(key.slice(prefix.length));
|
|
301
|
+
if (Number.isFinite(idx) && idx > frameIndex) {
|
|
302
|
+
this._frameCache.delete(key);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Removes [data-transition] and [data-direction] from a frame once its
|
|
308
|
+
// enter animation ends. This restores overflow-y:auto on the layer (the
|
|
309
|
+
// :has([data-transition]) rule in the CSS preset keeps overflow:hidden
|
|
310
|
+
// while the animation runs to clip off-screen slide frames).
|
|
311
|
+
#cleanupFrameTransition(frameEl) {
|
|
312
|
+
let done = false;
|
|
313
|
+
const cleanup = () => {
|
|
314
|
+
if (done) return;
|
|
315
|
+
done = true;
|
|
316
|
+
frameEl.removeAttribute("data-transition");
|
|
317
|
+
frameEl.removeAttribute("data-direction");
|
|
318
|
+
};
|
|
319
|
+
frameEl.addEventListener("transitionend", cleanup, { once: true });
|
|
320
|
+
setTimeout(cleanup, this.#leaveTimeoutMs());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#createFrameWrapper({ frameIndex, transition = null, direction = null }) {
|
|
324
|
+
const el = this.document.createElement("div");
|
|
325
|
+
el.dataset.modalStackFrame = "";
|
|
326
|
+
el.dataset.frameIndex = String(frameIndex);
|
|
327
|
+
if (transition) el.dataset.transition = transition;
|
|
328
|
+
if (direction) el.dataset.direction = direction;
|
|
329
|
+
return el;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#applyFrameDepth(layer, topFrameIndex) {
|
|
333
|
+
layer.dataset.frameIndex = String(topFrameIndex);
|
|
334
|
+
layer.dataset.frameDepth = String(topFrameIndex + 1);
|
|
335
|
+
}
|
|
336
|
+
|
|
165
337
|
#topLayer() {
|
|
166
338
|
const layers = this.dialog.querySelectorAll(LAYER_SELECTOR);
|
|
167
339
|
return layers[layers.length - 1] ?? null;
|
|
@@ -193,25 +365,28 @@ export class BrowserRuntime {
|
|
|
193
365
|
}
|
|
194
366
|
}
|
|
195
367
|
|
|
196
|
-
async fetchFragment(url) {
|
|
368
|
+
async fetchFragment(url, { signal } = {}) {
|
|
197
369
|
const resp = await this.fetcher(url, {
|
|
198
370
|
headers: {
|
|
199
371
|
Accept: "text/html, text/vnd.turbo-stream.html",
|
|
200
372
|
[FRAGMENT_HEADER]: "1",
|
|
201
373
|
},
|
|
202
374
|
credentials: "same-origin",
|
|
375
|
+
signal,
|
|
203
376
|
});
|
|
204
377
|
if (!resp.ok) {
|
|
205
378
|
throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
|
|
206
379
|
}
|
|
207
380
|
const html = await resp.text();
|
|
208
|
-
|
|
381
|
+
const stale = parseStaleHeader(resp);
|
|
382
|
+
return { fragment: parseFragment(html, this.document), stale };
|
|
209
383
|
}
|
|
210
384
|
|
|
211
385
|
async #resolveFragment({ url, html, fragment }) {
|
|
212
386
|
if (fragment) return fragment;
|
|
213
387
|
if (html != null) return parseFragment(html, this.document);
|
|
214
|
-
|
|
388
|
+
const result = await this.fetchFragment(url);
|
|
389
|
+
return result.fragment;
|
|
215
390
|
}
|
|
216
391
|
}
|
|
217
392
|
|
|
@@ -223,11 +398,33 @@ function parseFragment(html, doc) {
|
|
|
223
398
|
return fragment;
|
|
224
399
|
}
|
|
225
400
|
|
|
401
|
+
function parseStaleHeader(resp) {
|
|
402
|
+
const headers = resp?.headers;
|
|
403
|
+
const value =
|
|
404
|
+
typeof headers?.get === "function"
|
|
405
|
+
? headers.get(STALE_HEADER) ?? headers.get(STALE_HEADER.toLowerCase())
|
|
406
|
+
: null;
|
|
407
|
+
if (!value) return false;
|
|
408
|
+
const normalized = String(value).trim().toLowerCase();
|
|
409
|
+
return normalized === "true" || normalized === "1";
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function cloneFragment(fragment, doc) {
|
|
413
|
+
if (typeof fragment?.cloneNode === "function") {
|
|
414
|
+
return fragment.cloneNode(true);
|
|
415
|
+
}
|
|
416
|
+
const clone = doc.createDocumentFragment();
|
|
417
|
+
if (fragment?.childNodes) {
|
|
418
|
+
for (const node of fragment.childNodes) clone.appendChild(node.cloneNode(true));
|
|
419
|
+
}
|
|
420
|
+
return clone;
|
|
421
|
+
}
|
|
422
|
+
|
|
226
423
|
// Marks the layer with [data-leaving] so the host CSS can transition it
|
|
227
424
|
// out, then awaits transitionend (with a hard timeout) before removing
|
|
228
425
|
// the element from the DOM. If the host CSS doesn't define an exit
|
|
229
426
|
// transition, the timeout still fires and the layer is removed cleanly.
|
|
230
|
-
function animateOut(layer) {
|
|
427
|
+
function animateOut(layer, timeoutMs = LEAVE_TIMEOUT_FALLBACK_MS) {
|
|
231
428
|
return new Promise((resolve) => {
|
|
232
429
|
let done = false;
|
|
233
430
|
const finish = () => {
|
|
@@ -239,13 +436,25 @@ function animateOut(layer) {
|
|
|
239
436
|
};
|
|
240
437
|
layer.addEventListener("transitionend", finish, { once: true });
|
|
241
438
|
layer.dataset.leaving = "";
|
|
242
|
-
setTimeout(finish,
|
|
439
|
+
setTimeout(finish, timeoutMs);
|
|
243
440
|
});
|
|
244
441
|
}
|
|
245
442
|
|
|
443
|
+
// Parses a CSS time token ("220ms", "0.22s", " 220 ms ") to milliseconds.
|
|
444
|
+
// Returns NaN when the input is empty or unparseable so callers can fall back.
|
|
445
|
+
function parseDurationMs(raw) {
|
|
446
|
+
if (typeof raw !== "string") return NaN;
|
|
447
|
+
const value = raw.trim();
|
|
448
|
+
if (!value) return NaN;
|
|
449
|
+
const num = parseFloat(value);
|
|
450
|
+
if (!Number.isFinite(num)) return NaN;
|
|
451
|
+
return /m?s$/i.test(value) && !/ms$/i.test(value) ? num * 1000 : num;
|
|
452
|
+
}
|
|
453
|
+
|
|
246
454
|
function escapeAttr(value) {
|
|
247
455
|
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
248
456
|
return CSS.escape(value);
|
|
249
457
|
}
|
|
250
|
-
|
|
458
|
+
// Fallback: escape chars that break CSS attribute selectors ([attr="val"])
|
|
459
|
+
return String(value).replace(/["\\[\]]/g, "\\$&");
|
|
251
460
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FRAGMENT_HEADER,
|
|
5
5
|
SCROLLBAR_WIDTH_VAR,
|
|
6
6
|
SNAPSHOT_KEY,
|
|
7
|
+
STALE_HEADER,
|
|
7
8
|
} from "./runtime.js";
|
|
8
9
|
|
|
9
10
|
function fakeStore() {
|
|
@@ -179,6 +180,74 @@ describe("scroll lock", () => {
|
|
|
179
180
|
});
|
|
180
181
|
});
|
|
181
182
|
|
|
183
|
+
describe("leave timeout from CSS variable", () => {
|
|
184
|
+
function rtWithDuration(raw) {
|
|
185
|
+
const ownerDocument = {};
|
|
186
|
+
const dialog = {
|
|
187
|
+
ownerDocument,
|
|
188
|
+
querySelectorAll: () => [],
|
|
189
|
+
};
|
|
190
|
+
const documentRef = { documentElement: {}, body: {} };
|
|
191
|
+
const original = globalThis.getComputedStyle;
|
|
192
|
+
globalThis.getComputedStyle = () => ({
|
|
193
|
+
getPropertyValue: (name) =>
|
|
194
|
+
name === "--modal-stack-duration" ? raw : "",
|
|
195
|
+
});
|
|
196
|
+
const rt = new BrowserRuntime(
|
|
197
|
+
noopRuntimeArgs({ dialog, documentRef }),
|
|
198
|
+
);
|
|
199
|
+
return { rt, restore: () => (globalThis.getComputedStyle = original) };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function trigger(rt) {
|
|
203
|
+
// unmountAllLayers reads the timeout once (querying [] layers, so it
|
|
204
|
+
// resolves immediately) and stashes the result on _cachedLeaveTimeoutMs.
|
|
205
|
+
await rt.unmountAllLayers();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
test("derives 1.5x of the CSS variable, with a 300ms floor", async () => {
|
|
209
|
+
const { rt, restore } = rtWithDuration("220ms");
|
|
210
|
+
try {
|
|
211
|
+
await trigger(rt);
|
|
212
|
+
// 220ms × 1.5 = 330ms
|
|
213
|
+
expect(rt._cachedLeaveTimeoutMs).toBe(330);
|
|
214
|
+
} finally {
|
|
215
|
+
restore();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("floors the timeout at 300ms for very fast transitions", async () => {
|
|
220
|
+
const { rt, restore } = rtWithDuration("100ms");
|
|
221
|
+
try {
|
|
222
|
+
await trigger(rt);
|
|
223
|
+
expect(rt._cachedLeaveTimeoutMs).toBe(300);
|
|
224
|
+
} finally {
|
|
225
|
+
restore();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("falls back to 600 when the variable is empty", async () => {
|
|
230
|
+
const { rt, restore } = rtWithDuration("");
|
|
231
|
+
try {
|
|
232
|
+
await trigger(rt);
|
|
233
|
+
expect(rt._cachedLeaveTimeoutMs).toBe(600);
|
|
234
|
+
} finally {
|
|
235
|
+
restore();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("supports seconds units (e.g. 0.4s)", async () => {
|
|
240
|
+
const { rt, restore } = rtWithDuration("0.4s");
|
|
241
|
+
try {
|
|
242
|
+
await trigger(rt);
|
|
243
|
+
// 400ms × 1.5 = 600ms
|
|
244
|
+
expect(rt._cachedLeaveTimeoutMs).toBe(600);
|
|
245
|
+
} finally {
|
|
246
|
+
restore();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
182
251
|
describe("fetch headers", () => {
|
|
183
252
|
test("sends Accept and X-Modal-Stack-Request headers", async () => {
|
|
184
253
|
let captured = null;
|
|
@@ -200,4 +269,86 @@ describe("fetch headers", () => {
|
|
|
200
269
|
expect(captured.opts.headers.Accept).toContain("text/html");
|
|
201
270
|
expect(captured.opts.credentials).toBe("same-origin");
|
|
202
271
|
});
|
|
272
|
+
|
|
273
|
+
test("fetchFragment returns { fragment, stale } from response header", async () => {
|
|
274
|
+
withFakeDOMParser(async () => {
|
|
275
|
+
const fetcher = () =>
|
|
276
|
+
Promise.resolve(
|
|
277
|
+
new Response("<p>frame</p>", {
|
|
278
|
+
status: 200,
|
|
279
|
+
headers: { [STALE_HEADER]: "true" },
|
|
280
|
+
}),
|
|
281
|
+
);
|
|
282
|
+
const documentRef = fakeDocument();
|
|
283
|
+
const rt = new BrowserRuntime(noopRuntimeArgs({ fetcher, documentRef }));
|
|
284
|
+
const result = await rt.fetchFragment("/x");
|
|
285
|
+
expect(result.stale).toBe(true);
|
|
286
|
+
expect(result.fragment).toBeTruthy();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("fetchFragment.stale is false when header is missing or '0'", async () => {
|
|
291
|
+
await withFakeDOMParser(async () => {
|
|
292
|
+
const cases = [
|
|
293
|
+
new Response("", { status: 200 }),
|
|
294
|
+
new Response("", { status: 200, headers: { [STALE_HEADER]: "0" } }),
|
|
295
|
+
new Response("", { status: 200, headers: { [STALE_HEADER]: "false" } }),
|
|
296
|
+
];
|
|
297
|
+
for (const resp of cases) {
|
|
298
|
+
const documentRef = fakeDocument();
|
|
299
|
+
const rt = new BrowserRuntime(
|
|
300
|
+
noopRuntimeArgs({ fetcher: () => Promise.resolve(resp), documentRef }),
|
|
301
|
+
);
|
|
302
|
+
const result = await rt.fetchFragment("/x");
|
|
303
|
+
expect(result.stale).toBe(false);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
function fakeDocument() {
|
|
310
|
+
return {
|
|
311
|
+
createDocumentFragment: () => {
|
|
312
|
+
const children = [];
|
|
313
|
+
return {
|
|
314
|
+
childNodes: children,
|
|
315
|
+
append: (...nodes) => children.push(...nodes),
|
|
316
|
+
appendChild: (n) => children.push(n),
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function withFakeDOMParser(fn) {
|
|
323
|
+
const original = globalThis.DOMParser;
|
|
324
|
+
globalThis.DOMParser = class {
|
|
325
|
+
parseFromString() {
|
|
326
|
+
return { body: { childNodes: [] } };
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
try {
|
|
330
|
+
return await fn();
|
|
331
|
+
} finally {
|
|
332
|
+
if (original) globalThis.DOMParser = original;
|
|
333
|
+
else delete globalThis.DOMParser;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
describe("frame cache", () => {
|
|
338
|
+
test("clearFrameCache removes only entries for the given layerId", () => {
|
|
339
|
+
const rt = new BrowserRuntime(noopRuntimeArgs());
|
|
340
|
+
rt._frameCache.set("L1#0", "a");
|
|
341
|
+
rt._frameCache.set("L1#1", "b");
|
|
342
|
+
rt._frameCache.set("L2#0", "c");
|
|
343
|
+
rt._frameCache.set("L11#0", "d"); // verify prefix is anchored on `#`
|
|
344
|
+
rt.clearFrameCache({ layerId: "L1" });
|
|
345
|
+
expect([...rt._frameCache.keys()].sort()).toEqual(["L11#0", "L2#0"]);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("clearFrameCache is a no-op for unknown layerId", () => {
|
|
349
|
+
const rt = new BrowserRuntime(noopRuntimeArgs());
|
|
350
|
+
rt._frameCache.set("L1#0", "a");
|
|
351
|
+
rt.clearFrameCache({ layerId: "Lz" });
|
|
352
|
+
expect([...rt._frameCache.keys()]).toEqual(["L1#0"]);
|
|
353
|
+
});
|
|
203
354
|
});
|