modal_stack 0.3.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +113 -32
  4. data/app/assets/javascripts/modal_stack.js +488 -50
  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 +50 -0
  11. data/app/javascript/modal_stack/install.js +7 -1
  12. data/app/javascript/modal_stack/orchestrator.js +53 -7
  13. data/app/javascript/modal_stack/orchestrator.test.js +96 -0
  14. data/app/javascript/modal_stack/runtime.js +167 -5
  15. data/app/javascript/modal_stack/runtime.test.js +83 -0
  16. data/app/javascript/modal_stack/state.js +319 -34
  17. data/app/javascript/modal_stack/state.test.js +394 -9
  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 +1 -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
@@ -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,
@@ -104,25 +106,65 @@ export class Orchestrator {
104
106
  return this.#dispatch(replaceTop(this.state, patch, opts), { html, fragment });
105
107
  }
106
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
+
107
137
  async #prefetch(url) {
108
- if (typeof this.runtime.fetchFragment !== "function") return null;
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
+ }
109
146
 
110
147
  const cached = this.#fragmentCache.get(url);
111
148
  if (cached && Date.now() - cached.ts < this.prefetchTtlMs) {
112
- return cloneFragment(cached.fragment);
149
+ return { fragment: cloneFragment(cached.fragment), stale: cached.stale === true };
113
150
  }
114
151
 
115
152
  const existing = this.#inflight.get(url);
116
153
  if (existing) {
117
154
  const entry = await existing.promise;
118
- return cloneFragment(entry.fragment);
155
+ return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
119
156
  }
120
157
 
121
158
  const controller = supportsAbort() ? new AbortController() : null;
122
159
  const fetchPromise = this.runtime
123
160
  .fetchFragment(url, controller ? { signal: controller.signal } : undefined)
124
- .then((fragment) => {
125
- const entry = { fragment, ts: Date.now() };
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() };
126
168
  this.#fragmentCache.set(url, entry);
127
169
  return entry;
128
170
  })
@@ -132,7 +174,7 @@ export class Orchestrator {
132
174
 
133
175
  this.#inflight.set(url, { controller, promise: fetchPromise });
134
176
  const entry = await fetchPromise;
135
- return cloneFragment(entry.fragment);
177
+ return { fragment: cloneFragment(entry.fragment), stale: entry.stale === true };
136
178
  }
137
179
 
138
180
  // Aborts every in-flight prefetch and forgets any cached fragments.
@@ -183,7 +225,11 @@ export class Orchestrator {
183
225
  async #dispatch({ state, commands }, payload = {}) {
184
226
  this.state = state;
185
227
  for (const cmd of commands) {
186
- if (cmd.type === "mountLayer" || cmd.type === "morphTopLayer") {
228
+ if (
229
+ cmd.type === "mountLayer" ||
230
+ cmd.type === "morphTopLayer" ||
231
+ cmd.type === "mountFrame"
232
+ ) {
187
233
  if (payload.html != null) cmd.html = payload.html;
188
234
  if (payload.fragment != null) cmd.fragment = payload.fragment;
189
235
  }
@@ -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" });
@@ -210,6 +300,8 @@ describe("closeAll", () => {
210
300
  expect(types).toEqual([
211
301
  "closeDialog",
212
302
  "unmountAllLayers",
303
+ "clearFrameCache",
304
+ "clearFrameCache",
213
305
  "unlockScroll",
214
306
  "historyBack",
215
307
  "clearSnapshot",
@@ -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) {
@@ -467,6 +562,7 @@ describe("onPopstate", () => {
467
562
  expect(types).toEqual([
468
563
  "closeDialog",
469
564
  "unmountAllLayers",
565
+ "clearFrameCache",
470
566
  "unlockScroll",
471
567
  "clearSnapshot",
472
568
  ]);
@@ -1,8 +1,13 @@
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"]';
10
+ const FRAME_SELECTOR = "[data-modal-stack-frame]";
6
11
  // CSS variable host stylesheets set to declare their leave-transition
7
12
  // duration (e.g. "220ms"). When present, the runtime sizes its safety
8
13
  // timeout from this value; otherwise it falls back to a conservative cap.
@@ -48,6 +53,10 @@ export class BrowserRuntime {
48
53
  this.fetcher = fetcher;
49
54
  this.store = store;
50
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();
51
60
  }
52
61
 
53
62
  showDialog() {
@@ -93,7 +102,10 @@ export class BrowserRuntime {
93
102
  const frag = await this.#resolveFragment({ url, html, fragment });
94
103
  const layer = this.document.createElement("div");
95
104
  this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
96
- layer.append(...frag.childNodes);
105
+ this.#applyFrameDepth(layer, 0);
106
+ const wrapper = this.#createFrameWrapper({ frameIndex: 0 });
107
+ wrapper.append(...frag.childNodes);
108
+ layer.appendChild(wrapper);
97
109
  this.dialog.appendChild(layer);
98
110
  }
99
111
 
@@ -102,7 +114,81 @@ export class BrowserRuntime {
102
114
  const layer = this.#topLayer();
103
115
  if (!layer) return;
104
116
  this.#applyLayerAttrs(layer, { layerId, depth, variant, dismissible, size, side, width, height });
105
- layer.replaceChildren(...frag.childNodes);
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
+ }
106
192
  }
107
193
 
108
194
  async unmountTopLayer() {
@@ -197,6 +283,57 @@ export class BrowserRuntime {
197
283
  );
198
284
  }
199
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
+
200
337
  #topLayer() {
201
338
  const layers = this.dialog.querySelectorAll(LAYER_SELECTOR);
202
339
  return layers[layers.length - 1] ?? null;
@@ -241,13 +378,15 @@ export class BrowserRuntime {
241
378
  throw new Error(`modal_stack: fetch ${url} → ${resp.status}`);
242
379
  }
243
380
  const html = await resp.text();
244
- return parseFragment(html, this.document);
381
+ const stale = parseStaleHeader(resp);
382
+ return { fragment: parseFragment(html, this.document), stale };
245
383
  }
246
384
 
247
385
  async #resolveFragment({ url, html, fragment }) {
248
386
  if (fragment) return fragment;
249
387
  if (html != null) return parseFragment(html, this.document);
250
- return this.fetchFragment(url);
388
+ const result = await this.fetchFragment(url);
389
+ return result.fragment;
251
390
  }
252
391
  }
253
392
 
@@ -259,6 +398,28 @@ function parseFragment(html, doc) {
259
398
  return fragment;
260
399
  }
261
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
+
262
423
  // Marks the layer with [data-leaving] so the host CSS can transition it
263
424
  // out, then awaits transitionend (with a hard timeout) before removing
264
425
  // the element from the DOM. If the host CSS doesn't define an exit
@@ -294,5 +455,6 @@ function escapeAttr(value) {
294
455
  if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
295
456
  return CSS.escape(value);
296
457
  }
297
- return String(value).replace(/["\\]/g, "\\$&");
458
+ // Fallback: escape chars that break CSS attribute selectors ([attr="val"])
459
+ return String(value).replace(/["\\[\]]/g, "\\$&");
298
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() {
@@ -268,4 +269,86 @@ describe("fetch headers", () => {
268
269
  expect(captured.opts.headers.Accept).toContain("text/html");
269
270
  expect(captured.opts.credentials).toBe("same-origin");
270
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
+ });
271
354
  });