Public: anyone can read this; the crew can change it.

A live region marked `data-live-hold` is not swapped while the reader is scrolled into it; the client hands the fresh markup to the page instead #11629

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6693-a-live-region-marked-data-live-hold-is-n → main 8 commits fork

  • Merged. scout/6693-a-live-region-marked-data-live-hold-is-n was merged into main as 515754a.

Review

Sign in to review this change.
3 files changed +105 −4
src/web/client.ts +6 −3
@@ -2406,7 +2406,9 @@ export const liveRegionsJs = /* js */ `
24062406 var t = /<time\\b[^>]*>[^<]*<\\/time>/g;
24072407 return a.replace(t, '') === b.replace(t, '');
24082408 }
2409 function swap(fresh, ids) {
2409 // data-live-hold: a reader inside the region keeps their place.
2410 function holdRegion(r) { var b = r.hasAttribute('data-live-hold') && r.getBoundingClientRect(); return b && b.top < 0 && b.bottom > 0; }
2411 function swap(fresh, ids, src) {
24102412 ids.forEach(function (id) {
24112413 var mine = document.getElementById(id);
24122414 var theirs = fresh.getElementById(id);
@@ -2425,6 +2427,7 @@ export const liveRegionsJs = /* js */ `
24252427 var typed = Array.prototype.some.call(mine.querySelectorAll('input.input, textarea'), function (i) { return i.value !== ''; });
24262428 if (typed) return;
24272429 if (same(mine.innerHTML, theirs.innerHTML)) return;
2430 if (holdRegion(mine)) { delete tags[src]; mine.dispatchEvent(new CustomEvent('forge:held', { bubbles: true, detail: { html: theirs.innerHTML } })); return; }
24282431 // A disclosure the reader opened stays open across the swap.
24292432 var opened = Array.prototype.map.call(mine.querySelectorAll('details[id][open]'), function (d) { return d.id; });
24302433 mine.innerHTML = theirs.innerHTML;
@@ -2459,7 +2462,7 @@ export const liveRegionsJs = /* js */ `
24592462 holder.innerHTML = text;
24602463 var stamped = holder.content.querySelector('[data-etag]');
24612464 if (stamped) tags[src] = '"' + stamped.getAttribute('data-etag') + '"';
2462 swap(holder.content, ids);
2465 swap(holder.content, ids, src);
24632466 return;
24642467 }
24652468 // Parse a copy without the page's own style element: the swap only
@@ -2473,7 +2476,7 @@ export const liveRegionsJs = /* js */ `
24732476 var theirSprite = fresh.querySelector('.sprite');
24742477 var mySprite = document.querySelector('.sprite');
24752478 if (theirSprite && mySprite) mySprite.replaceWith(theirSprite);
2476 swap(fresh, ids);
2479 swap(fresh, ids, src);
24772480 })
24782481 .catch(function () {});
24792482 }
test/flow-handover-page.test.ts +1 −1
@@ -27,7 +27,7 @@ const rows = (out: string, title: string): number => {
2727 return list.match(/<li>/g)?.length ?? 0;
2828 };
2929
30 const NOW = Date.parse("2026-11-11T12:00:00Z");
30 const NOW = Date.now(); // /flow reads the real clock, so a fixed date ages out of its one-day window
3131 const ago = (h: number) => new Date(NOW - h * 3_600_000).toISOString();
3232
3333 let app: App;
test/live-hold.test.ts added +98
@@ -0,0 +1,98 @@
1 /**
2 * A live region marked data-live-hold is not swapped while the reader is
3 * scrolled into it (#6693): the client says `forge:held` with the fresh markup.
4 */
5
6 import { describe, expect, test } from "bun:test";
7 import { liveRegionsJs } from "../src/web/client.ts";
8
9 // eslint-disable-next-line @typescript-eslint/no-explicit-any
10 type Any = any;
11
12 async function run(hold: boolean, top: number, bottom = 400) {
13 const view = { top };
14 const events: { type: string; detail?: Any }[] = [];
15 const region: Any = {
16 id: "r",
17 innerHTML: "<p>old</p>",
18 attributes: [],
19 getAttribute: (n: string) => (n === "data-live" ? "run.*" : null),
20 hasAttribute: (n: string) => hold && n === "data-live-hold",
21 removeAttribute() {},
22 setAttribute() {},
23 querySelector: () => null,
24 querySelectorAll: () => [],
25 contains: () => false,
26 getBoundingClientRect: () => ({ top: view.top, bottom }),
27 dispatchEvent: (e: Any) => events.push(e),
28 };
29 const fresh: Any = { ...region, innerHTML: "<p>new</p>", attributes: [] };
30 const handlers: Record<string, (e: Any) => void> = {};
31 class ES {
32 addEventListener(t: string, f: (e: Any) => void) { handlers[t] = f; }
33 close() {}
34 }
35 const doc: Any = {
36 hidden: false,
37 activeElement: null,
38 querySelectorAll: (sel: string) => (sel === "[data-live]" ? [region] : []),
39 getElementById: () => region,
40 querySelector: () => null,
41 createElement: () => ({
42 set innerHTML(_v: string) {},
43 content: { getElementById: () => fresh, querySelector: () => null },
44 }),
45 addEventListener() {},
46 dispatchEvent() {},
47 };
48 const win: Any = { addEventListener() {}, setInterval: () => 1, clearInterval() {} };
49 // The server's answer never changes: it honours If-None-Match as a real one does.
50 const fetchFn = async (_u: string, init: Any) =>
51 init.headers["if-none-match"] === '"v1"'
52 ? { status: 304, ok: false, headers: { get: () => null }, text: async () => "" }
53 : { status: 200, ok: true, headers: { get: () => '"v1"' }, text: async () => "x" };
54 class DP { parseFromString() { return { getElementById: () => fresh, querySelector: () => null }; } }
55 class CE { constructor(public type: string, init: Any = {}) { Object.assign(this, init); } }
56 new Function("document", "window", "EventSource", "CustomEvent", "fetch", "location", "setTimeout", "Date", "DOMParser", liveRegionsJs)(
57 doc, win, ES, CE, fetchFn, { href: "http://x/" }, (f: () => void) => { queueMicrotask(f); return 1; }, Date, DP,
58 );
59 const poke = async () => {
60 handlers.forge!({ lastEventId: "1", data: JSON.stringify({ kind: "run.done", repo: "a" }) });
61 await new Promise((r) => setTimeout(r, 20));
62 };
63 await poke();
64 return { region, events, view, poke };
65 }
66
67 describe("data-live-hold", () => {
68 test("scrolled into the region: not swapped, forge:held carries the fresh markup", async () => {
69 const { region, events } = await run(true, -40);
70 expect(region.innerHTML).toBe("<p>old</p>");
71 const held = events.find((e) => e.type === "forge:held") as Any;
72 expect(held.bubbles).toBe(true);
73 expect(held.detail.html).toBe("<p>new</p>");
74 expect(events.some((e) => e.type === "forge:swapped")).toBe(false);
75 });
76 test("at the top: swapped as ever", async () => {
77 const { region, events } = await run(true, 0);
78 expect(region.innerHTML).toBe("<p>new</p>");
79 expect(events.map((e) => e.type)).toEqual(["forge:swapped"]);
80 });
81 test("without the attribute: swapped even when scrolled", async () => {
82 const { region, events } = await run(false, -40);
83 expect(region.innerHTML).toBe("<p>new</p>");
84 expect(events.map((e) => e.type)).toEqual(["forge:swapped"]);
85 });
86 test("entirely above the viewport: not read, so swapped", async () => {
87 const { region } = await run(true, -500, -100);
88 expect(region.innerHTML).toBe("<p>new</p>");
89 });
90 test("held, then the reader returns to the top: the next pull swaps without a server change", async () => {
91 const { region, events, view, poke } = await run(true, -40);
92 expect(region.innerHTML).toBe("<p>old</p>");
93 view.top = 0;
94 await poke();
95 expect(region.innerHTML).toBe("<p>new</p>");
96 expect(events.map((e) => e.type)).toEqual(["forge:held", "forge:swapped"]);
97 });
98 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs