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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +187 -36
  4. data/app/assets/javascripts/modal_stack.js +693 -73
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +113 -3
  6. data/app/assets/stylesheets/modal_stack/tailwind_v3.css +63 -2
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +63 -2
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +121 -3
  9. data/app/javascript/modal_stack/controllers/modal_stack_back_link_controller.js +32 -0
  10. data/app/javascript/modal_stack/controllers/modal_stack_controller.js +161 -8
  11. data/app/javascript/modal_stack/install.js +7 -1
  12. data/app/javascript/modal_stack/orchestrator.js +70 -10
  13. data/app/javascript/modal_stack/orchestrator.test.js +98 -2
  14. data/app/javascript/modal_stack/runtime.js +316 -9
  15. data/app/javascript/modal_stack/runtime.test.js +90 -6
  16. data/app/javascript/modal_stack/state.js +343 -45
  17. data/app/javascript/modal_stack/state.test.js +404 -17
  18. data/app/views/modal_stack/_dialog.html.erb +1 -0
  19. data/app/views/modal_stack/_panel.html.erb +4 -0
  20. data/lib/generators/modal_stack/install/templates/initializer.rb +9 -0
  21. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  22. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  23. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  24. data/lib/modal_stack/capybara.rb +21 -0
  25. data/lib/modal_stack/configuration.rb +37 -16
  26. data/lib/modal_stack/engine.rb +2 -0
  27. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  28. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +7 -1
  29. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  30. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  31. data/lib/modal_stack/version.rb +1 -1
  32. data/lib/modal_stack.rb +5 -1
  33. metadata +9 -2
@@ -1,8 +1,14 @@
1
1
  export const SNAPSHOT_KEY = "modalStackSnapshot";
2
+ export const FRAME_HTML_KEY = "modalStackFrameHtml";
2
3
  export const FRAGMENT_HEADER = "X-Modal-Stack-Request";
4
+ // Server response header that flags the just-rendered frame as stale —
5
+ // the runtime will refetch instead of restoring from the in-memory cache
6
+ // when the user steps back to it.
7
+ export const STALE_HEADER = "X-Modal-Stack-Stale";
3
8
  export const SCROLLBAR_WIDTH_VAR = "--modal-stack-scrollbar-width";
4
9
 
5
10
  const LAYER_SELECTOR = '[data-modal-stack-target="layer"]';
11
+ const FRAME_SELECTOR = "[data-modal-stack-frame]";
6
12
  // CSS variable host stylesheets set to declare their leave-transition
7
13
  // duration (e.g. "220ms"). When present, the runtime sizes its safety
8
14
  // timeout from this value; otherwise it falls back to a conservative cap.
@@ -22,11 +28,17 @@ const LEAVE_TIMEOUT_FALLBACK_MS = 600;
22
28
  * `orchestrator.test.js` for an in-memory fake).
23
29
  */
24
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
+
25
36
  /**
26
37
  * @param {Object} options
27
38
  * @param {HTMLDialogElement} options.dialog
28
39
  * @param {HTMLElement} [options.body]
29
40
  * @param {History} [options.history]
41
+ * @param {Location} [options.location]
30
42
  * @param {typeof fetch} [options.fetcher]
31
43
  * @param {Storage} [options.store]
32
44
  * @param {Document} [options.documentRef]
@@ -35,6 +47,7 @@ export class BrowserRuntime {
35
47
  dialog,
36
48
  body = globalThis.document?.body,
37
49
  history = globalThis.history,
50
+ location = globalThis.location,
38
51
  fetcher = globalThis.fetch?.bind(globalThis),
39
52
  store = globalThis.sessionStorage,
40
53
  documentRef = globalThis.document,
@@ -45,9 +58,52 @@ export class BrowserRuntime {
45
58
  this.dialog = dialog;
46
59
  this.body = body;
47
60
  this.history = history;
61
+ this.location = location;
48
62
  this.fetcher = fetcher;
49
63
  this.store = store;
50
64
  this.document = documentRef;
65
+ // Cached DocumentFragments for path frames, keyed by `${layerId}#${frameIndex}`.
66
+ // Populated by mountFrame on the way forward, drained by unmountFrame on
67
+ // the way back, purged on layer teardown via clearFrameCache.
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);
51
107
  }
52
108
 
53
109
  showDialog() {
@@ -93,7 +149,10 @@ export class BrowserRuntime {
93
149
  const frag = await this.#resolveFragment({ url, html, fragment });
94
150
  const layer = this.document.createElement("div");
95
151
  this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
96
- layer.append(...frag.childNodes);
152
+ this.#applyFrameDepth(layer, 0);
153
+ const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
154
+ wrapper.append(...frag.childNodes);
155
+ layer.appendChild(wrapper);
97
156
  this.dialog.appendChild(layer);
98
157
  }
99
158
 
@@ -102,7 +161,108 @@ export class BrowserRuntime {
102
161
  const layer = this.#topLayer();
103
162
  if (!layer) return;
104
163
  this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
105
- layer.replaceChildren(...frag.childNodes);
164
+ this.#applyFrameDepth(layer, 0);
165
+ const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
166
+ wrapper.append(...frag.childNodes);
167
+ layer.replaceChildren(wrapper);
168
+ }
169
+
170
+ async mountFrame({ layerId, fromFrameIndex, toFrameIndex, url, html, fragment, transition }) {
171
+ const layer = this.#findLayer(layerId);
172
+ if (!layer) return;
173
+ const frag = await this.#resolveFragment({ url, html, fragment });
174
+
175
+ // Snapshot the outgoing frame's children for back navigation. We cache
176
+ // the original nodes (not clones) and detach them from the DOM — the
177
+ // animateOut below operates on the wrapper, which we'll throw away.
178
+ const oldFrame = this.#findFrame(layer, fromFrameIndex);
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
+ });
184
+ const cached = this.document.createDocumentFragment();
185
+ cached.append(...oldFrame.childNodes);
186
+ this._frameCache.set(this.#frameKey(layerId, fromFrameIndex), cached);
187
+ }
188
+
189
+ const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "forward" });
190
+ newFrame.append(...frag.childNodes);
191
+ layer.appendChild(newFrame);
192
+ this.#applyFrameDepth(layer, toFrameIndex);
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
+
201
+ // Old frame is removed synchronously. Entering frames carry
202
+ // data-transition + data-direction so host CSS can drive the *enter*
203
+ // animation via @starting-style; the leaving frame would need its own
204
+ // overlapping layout (e.g. position: absolute) to also animate out,
205
+ // which we leave to the host CSS preset.
206
+ if (oldFrame) oldFrame.remove();
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
+
213
+ // Remove transition attrs once the animation completes so the :has()
214
+ // rule that clips overflow doesn't persist indefinitely.
215
+ if (transition) this.#cleanupFrameTransition(newFrame);
216
+ }
217
+
218
+ async unmountFrame({ layerId, fromFrameIndex, toFrameIndex, url, stale, transition }) {
219
+ const layer = this.#findLayer(layerId);
220
+ if (!layer) return;
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
+
227
+ const cacheKey = this.#frameKey(layerId, toFrameIndex);
228
+ let restored = stale ? null : this._frameCache.get(cacheKey) ?? null;
229
+ if (!restored) {
230
+ const result = await this.fetchFragment(url);
231
+ restored = result.fragment;
232
+ this._frameCache.set(cacheKey, cloneFragment(restored, this.document));
233
+ } else {
234
+ // Clone so the cache entry remains usable if we step back here again
235
+ // after another forward.
236
+ restored = cloneFragment(restored, this.document);
237
+ }
238
+
239
+ const newFrame = this.#createFrameWrapper({ frameIndex: toFrameIndex, transition, direction: "back" });
240
+ newFrame.append(...restored.childNodes);
241
+ layer.appendChild(newFrame);
242
+ this.#applyFrameDepth(layer, toFrameIndex);
243
+
244
+ // Drop cache entries for frames that are now gone (anything past the
245
+ // restored index — we only keep entries for frames still tracked in
246
+ // state).
247
+ this.#purgeFrameCacheAbove(layerId, toFrameIndex);
248
+
249
+ const oldFrame = this.#findFrame(layer, fromFrameIndex);
250
+ if (oldFrame) oldFrame.remove();
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
+
257
+ if (transition) this.#cleanupFrameTransition(newFrame);
258
+ }
259
+
260
+ clearFrameCache({ layerId }) {
261
+ const prefix = `${layerId}#`;
262
+ for (const key of [...this._frameCache.keys()]) {
263
+ if (key.startsWith(prefix)) this._frameCache.delete(key);
264
+ }
265
+ this.#removeFrameHtmlForLayer(layerId);
106
266
  }
107
267
 
108
268
  async unmountTopLayer() {
@@ -117,15 +277,25 @@ export class BrowserRuntime {
117
277
  await Promise.all(layers.map((l) => animateOut(l, timeout)));
118
278
  }
119
279
 
120
- pushHistory({ url, historyState }) {
121
- this.history.pushState(historyState, "", url);
280
+ pushHistory({ historyState }) {
281
+ this.history.pushState(historyState, "", this.location?.href ?? "");
122
282
  }
123
283
 
124
- replaceHistory({ url, historyState }) {
125
- this.history.replaceState(historyState, "", url);
284
+ replaceHistory({ historyState }) {
285
+ this.history.replaceState(historyState, "", this.location?.href ?? "");
126
286
  }
127
287
 
128
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);
129
299
  this.history.go(-n);
130
300
  }
131
301
 
@@ -149,6 +319,7 @@ export class BrowserRuntime {
149
319
  if (!this.store) return;
150
320
  try {
151
321
  this.store.removeItem(SNAPSHOT_KEY);
322
+ this.store.removeItem(FRAME_HTML_KEY);
152
323
  } catch {
153
324
  // ignore
154
325
  }
@@ -163,6 +334,60 @@ export class BrowserRuntime {
163
334
  }
164
335
  }
165
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
+
166
391
  // Reads --modal-stack-duration from the dialog's computed style and
167
392
  // returns 1.5× that as the safety timeout (in ms). Cached after the
168
393
  // first successful read since the variable is host-CSS-defined and
@@ -197,6 +422,63 @@ export class BrowserRuntime {
197
422
  );
198
423
  }
199
424
 
425
+ #findFrame(layer, frameIndex) {
426
+ return layer.querySelector(
427
+ `${FRAME_SELECTOR}[data-frame-index="${escapeAttr(String(frameIndex))}"]`,
428
+ );
429
+ }
430
+
431
+ #frameKey(layerId, frameIndex) {
432
+ return `${layerId}#${frameIndex}`;
433
+ }
434
+
435
+ #dispatchFrameEvent(name, detail) {
436
+ this.dialog.dispatchEvent(
437
+ new CustomEvent(name, { bubbles: true, detail }),
438
+ );
439
+ }
440
+
441
+ #purgeFrameCacheAbove(layerId, frameIndex) {
442
+ const prefix = `${layerId}#`;
443
+ for (const key of [...this._frameCache.keys()]) {
444
+ if (!key.startsWith(prefix)) continue;
445
+ const idx = Number(key.slice(prefix.length));
446
+ if (Number.isFinite(idx) && idx > frameIndex) {
447
+ this._frameCache.delete(key);
448
+ }
449
+ }
450
+ }
451
+
452
+ // Removes [data-transition] and [data-direction] from a frame once its
453
+ // enter animation ends. This restores overflow-y:auto on the layer (the
454
+ // :has([data-transition]) rule in the CSS preset keeps overflow:hidden
455
+ // while the animation runs to clip off-screen slide frames).
456
+ #cleanupFrameTransition(frameEl) {
457
+ let done = false;
458
+ const cleanup = () => {
459
+ if (done) return;
460
+ done = true;
461
+ frameEl.removeAttribute("data-transition");
462
+ frameEl.removeAttribute("data-direction");
463
+ };
464
+ frameEl.addEventListener("transitionend", cleanup, { once: true });
465
+ setTimeout(cleanup, this.#leaveTimeoutMs());
466
+ }
467
+
468
+ #createFrameWrapper({ frameIndex, transition = null, direction = null }) {
469
+ const el = this.document.createElement("div");
470
+ el.dataset.modalStackFrame = "";
471
+ el.dataset.frameIndex = String(frameIndex);
472
+ if (transition) el.dataset.transition = transition;
473
+ if (direction) el.dataset.direction = direction;
474
+ return el;
475
+ }
476
+
477
+ #applyFrameDepth(layer, topFrameIndex) {
478
+ layer.dataset.frameIndex = String(topFrameIndex);
479
+ layer.dataset.frameDepth = String(topFrameIndex + 1);
480
+ }
481
+
200
482
  #topLayer() {
201
483
  const layers = this.dialog.querySelectorAll(LAYER_SELECTOR);
202
484
  return layers[layers.length - 1] ?? null;
@@ -241,13 +523,15 @@ export class BrowserRuntime {
241
523
  throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
242
524
  }
243
525
  const html = await resp.text();
244
- return parseFragment(html, this.document);
526
+ const stale = parseStaleHeader(resp);
527
+ return { fragment: parseFragment(html, this.document), stale };
245
528
  }
246
529
 
247
530
  async #resolveFragment({ url, html, fragment }) {
248
531
  if (fragment) return fragment;
249
532
  if (html != null) return parseFragment(html, this.document);
250
- return this.fetchFragment(url);
533
+ const result = await this.fetchFragment(url);
534
+ return result.fragment;
251
535
  }
252
536
  }
253
537
 
@@ -259,6 +543,28 @@ function parseFragment(html, doc) {
259
543
  return fragment;
260
544
  }
261
545
 
546
+ function parseStaleHeader(resp) {
547
+ const headers = resp?.headers;
548
+ const value =
549
+ typeof headers?.get === "function"
550
+ ? headers.get(STALE_HEADER) ?? headers.get(STALE_HEADER.toLowerCase())
551
+ : null;
552
+ if (!value) return false;
553
+ const normalized = String(value).trim().toLowerCase();
554
+ return normalized === "true" || normalized === "1";
555
+ }
556
+
557
+ function cloneFragment(fragment, doc) {
558
+ if (typeof fragment?.cloneNode === "function") {
559
+ return fragment.cloneNode(true);
560
+ }
561
+ const clone = doc.createDocumentFragment();
562
+ if (fragment?.childNodes) {
563
+ for (const node of fragment.childNodes) clone.appendChild(node.cloneNode(true));
564
+ }
565
+ return clone;
566
+ }
567
+
262
568
  // Marks the layer with [data-leaving] so the host CSS can transition it
263
569
  // out, then awaits transitionend (with a hard timeout) before removing
264
570
  // the element from the DOM. If the host CSS doesn't define an exit
@@ -294,5 +600,6 @@ function escapeAttr(value) {
294
600
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
295
601
  return CSS.escape(value);
296
602
  }
297
- return String(value).replace(/["\\]/g, "\\$&");
603
+ // Fallback: escape chars that break CSS attribute selectors ([attr="val"])
604
+ return String(value).replace(/["\\[\]]/g, "\\$&");
298
605
  }
@@ -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() {
@@ -96,20 +97,21 @@ describe("snapshot storage", () => {
96
97
  });
97
98
 
98
99
  describe("history wiring", () => {
99
- test("pushHistory / replaceHistory / historyBack delegate to history", () => {
100
+ test("pushHistory / replaceHistory always use current location.href, ignoring the modal url", () => {
100
101
  const calls = [];
101
102
  const history = {
102
103
  pushState: (s, t, u) => calls.push(["push", s, t, u]),
103
104
  replaceState: (s, t, u) => calls.push(["replace", s, t, u]),
104
105
  go: (n) => calls.push(["go", n]),
105
106
  };
106
- const rt = new BrowserRuntime(noopRuntimeArgs({ history }));
107
- rt.pushHistory({ url: "/a", historyState: { x: 1 } });
108
- rt.replaceHistory({ url: "/b", historyState: { y: 2 } });
107
+ const location = { href: "http://example.com/origin" };
108
+ const rt = new BrowserRuntime(noopRuntimeArgs({ history, location }));
109
+ rt.pushHistory({ url: "/modal-a", historyState: { x: 1 } });
110
+ rt.replaceHistory({ url: "/modal-b", historyState: { y: 2 } });
109
111
  rt.historyBack({ n: 3 });
110
112
  expect(calls).toEqual([
111
- ["push", { x: 1 }, "", "/a"],
112
- ["replace", { y: 2 }, "", "/b"],
113
+ ["push", { x: 1 }, "", "http://example.com/origin"],
114
+ ["replace", { y: 2 }, "", "http://example.com/origin"],
113
115
  ["go", -3],
114
116
  ]);
115
117
  });
@@ -268,4 +270,86 @@ describe("fetch headers", () => {
268
270
  expect(captured.opts.headers.Accept).toContain("text/html");
269
271
  expect(captured.opts.credentials).toBe("same-origin");
270
272
  });
273
+
274
+ test("fetchFragment returns { fragment, stale } from response header", async () => {
275
+ withFakeDOMParser(async () => {
276
+ const fetcher = () =>
277
+ Promise.resolve(
278
+ new Response("<p>frame</p>", {
279
+ status: 200,
280
+ headers: { [STALE_HEADER]: "true" },
281
+ }),
282
+ );
283
+ const documentRef = fakeDocument();
284
+ const rt = new BrowserRuntime(noopRuntimeArgs({ fetcher, documentRef }));
285
+ const result = await rt.fetchFragment("/x");
286
+ expect(result.stale).toBe(true);
287
+ expect(result.fragment).toBeTruthy();
288
+ });
289
+ });
290
+
291
+ test("fetchFragment.stale is false when header is missing or '0'", async () => {
292
+ await withFakeDOMParser(async () => {
293
+ const cases = [
294
+ new Response("", { status: 200 }),
295
+ new Response("", { status: 200, headers: { [STALE_HEADER]: "0" } }),
296
+ new Response("", { status: 200, headers: { [STALE_HEADER]: "false" } }),
297
+ ];
298
+ for (const resp of cases) {
299
+ const documentRef = fakeDocument();
300
+ const rt = new BrowserRuntime(
301
+ noopRuntimeArgs({ fetcher: () => Promise.resolve(resp), documentRef }),
302
+ );
303
+ const result = await rt.fetchFragment("/x");
304
+ expect(result.stale).toBe(false);
305
+ }
306
+ });
307
+ });
308
+ });
309
+
310
+ function fakeDocument() {
311
+ return {
312
+ createDocumentFragment: () => {
313
+ const children = [];
314
+ return {
315
+ childNodes: children,
316
+ append: (...nodes) => children.push(...nodes),
317
+ appendChild: (n) => children.push(n),
318
+ };
319
+ },
320
+ };
321
+ }
322
+
323
+ async function withFakeDOMParser(fn) {
324
+ const original = globalThis.DOMParser;
325
+ globalThis.DOMParser = class {
326
+ parseFromString() {
327
+ return { body: { childNodes: [] } };
328
+ }
329
+ };
330
+ try {
331
+ return await fn();
332
+ } finally {
333
+ if (original) globalThis.DOMParser = original;
334
+ else delete globalThis.DOMParser;
335
+ }
336
+ }
337
+
338
+ describe("frame cache", () => {
339
+ test("clearFrameCache removes only entries for the given layerId", () => {
340
+ const rt = new BrowserRuntime(noopRuntimeArgs());
341
+ rt._frameCache.set("L1#0", "a");
342
+ rt._frameCache.set("L1#1", "b");
343
+ rt._frameCache.set("L2#0", "c");
344
+ rt._frameCache.set("L11#0", "d"); // verify prefix is anchored on `#`
345
+ rt.clearFrameCache({ layerId: "L1" });
346
+ expect([...rt._frameCache.keys()].sort()).toEqual(["L11#0", "L2#0"]);
347
+ });
348
+
349
+ test("clearFrameCache is a no-op for unknown layerId", () => {
350
+ const rt = new BrowserRuntime(noopRuntimeArgs());
351
+ rt._frameCache.set("L1#0", "a");
352
+ rt.clearFrameCache({ layerId: "Lz" });
353
+ expect([...rt._frameCache.keys()]).toEqual(["L1#0"]);
354
+ });
271
355
  });