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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +136 -52
  4. data/app/assets/javascripts/modal_stack.js +612 -63
  5. data/app/assets/stylesheets/modal_stack/bootstrap.css +120 -11
  6. data/app/assets/stylesheets/modal_stack/{tailwind.css → tailwind_v3.css} +82 -14
  7. data/app/assets/stylesheets/modal_stack/tailwind_v4.css +372 -0
  8. data/app/assets/stylesheets/modal_stack/vanilla.css +128 -11
  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 +54 -0
  11. data/app/javascript/modal_stack/controllers/modal_stack_link_controller.js +29 -7
  12. data/app/javascript/modal_stack/install.js +7 -1
  13. data/app/javascript/modal_stack/orchestrator.js +132 -3
  14. data/app/javascript/modal_stack/orchestrator.test.js +264 -2
  15. data/app/javascript/modal_stack/runtime.js +222 -13
  16. data/app/javascript/modal_stack/runtime.test.js +151 -0
  17. data/app/javascript/modal_stack/state.js +338 -39
  18. data/app/javascript/modal_stack/state.test.js +400 -13
  19. data/app/views/modal_stack/_dialog.html.erb +1 -0
  20. data/app/views/modal_stack/_panel.html.erb +4 -0
  21. data/lib/generators/modal_stack/install/install_generator.rb +18 -4
  22. data/lib/generators/modal_stack/install/templates/initializer.rb +21 -5
  23. data/lib/generators/modal_stack/views/templates/_dialog.html.erb +9 -0
  24. data/lib/generators/modal_stack/views/templates/_panel.html.erb +18 -0
  25. data/lib/generators/modal_stack/views/views_generator.rb +50 -0
  26. data/lib/modal_stack/capybara.rb +21 -0
  27. data/lib/modal_stack/configuration.rb +43 -17
  28. data/lib/modal_stack/controller_extensions.rb +8 -1
  29. data/lib/modal_stack/engine.rb +2 -0
  30. data/lib/modal_stack/helpers/modal_back_link_helper.rb +48 -0
  31. data/lib/modal_stack/helpers/modal_stack_assets_helper.rb +15 -3
  32. data/lib/modal_stack/helpers/modal_stack_container_helper.rb +42 -15
  33. data/lib/modal_stack/turbo_streams_extension.rb +56 -0
  34. data/lib/modal_stack/version.rb +1 -1
  35. data/lib/modal_stack.rb +5 -1
  36. 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
- // Hard cap: never wait longer than this for an exit transition to fire,
7
- // even if the host CSS forgot to transition the leaving state.
8
- const LEAVE_TIMEOUT_MS = 600;
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.append(...frag.childNodes);
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.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
+ }
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
- await Promise.all(layers.map(animateOut));
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
- return parseFragment(html, this.document);
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
- return this.fetchFragment(url);
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, LEAVE_TIMEOUT_MS);
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
- return String(value).replace(/["\\]/g, "\\$&");
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
  });