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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6693-a-live-region-marked-data-live-hold-is-n → main 8 commits fork
3 files
changed +105
−4
src/web/client.ts +6 −3
| @@ -2406,7 +2406,9 @@ export const liveRegionsJs = /* js */ ` | ||
| 2406 | 2406 | var t = /<time\\b[^>]*>[^<]*<\\/time>/g; |
| 2407 | 2407 | return a.replace(t, '') === b.replace(t, ''); |
| 2408 | 2408 | } |
| 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) { | |
| 2410 | 2412 | ids.forEach(function (id) { |
| 2411 | 2413 | var mine = document.getElementById(id); |
| 2412 | 2414 | var theirs = fresh.getElementById(id); |
| @@ -2425,6 +2427,7 @@ export const liveRegionsJs = /* js */ ` | ||
| 2425 | 2427 | var typed = Array.prototype.some.call(mine.querySelectorAll('input.input, textarea'), function (i) { return i.value !== ''; }); |
| 2426 | 2428 | if (typed) return; |
| 2427 | 2429 | 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; } | |
| 2428 | 2431 | // A disclosure the reader opened stays open across the swap. |
| 2429 | 2432 | var opened = Array.prototype.map.call(mine.querySelectorAll('details[id][open]'), function (d) { return d.id; }); |
| 2430 | 2433 | mine.innerHTML = theirs.innerHTML; |
| @@ -2459,7 +2462,7 @@ export const liveRegionsJs = /* js */ ` | ||
| 2459 | 2462 | holder.innerHTML = text; |
| 2460 | 2463 | var stamped = holder.content.querySelector('[data-etag]'); |
| 2461 | 2464 | if (stamped) tags[src] = '"' + stamped.getAttribute('data-etag') + '"'; |
| 2462 | swap(holder.content, ids); | |
| 2465 | swap(holder.content, ids, src); | |
| 2463 | 2466 | return; |
| 2464 | 2467 | } |
| 2465 | 2468 | // Parse a copy without the page's own style element: the swap only |
| @@ -2473,7 +2476,7 @@ export const liveRegionsJs = /* js */ ` | ||
| 2473 | 2476 | var theirSprite = fresh.querySelector('.sprite'); |
| 2474 | 2477 | var mySprite = document.querySelector('.sprite'); |
| 2475 | 2478 | if (theirSprite && mySprite) mySprite.replaceWith(theirSprite); |
| 2476 | swap(fresh, ids); | |
| 2479 | swap(fresh, ids, src); | |
| 2477 | 2480 | }) |
| 2478 | 2481 | .catch(function () {}); |
| 2479 | 2482 | } |
test/flow-handover-page.test.ts +1 −1
| @@ -27,7 +27,7 @@ const rows = (out: string, title: string): number => { | ||
| 27 | 27 | return list.match(/<li>/g)?.length ?? 0; |
| 28 | 28 | }; |
| 29 | 29 | |
| 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 | |
| 31 | 31 | const ago = (h: number) => new Date(NOW - h * 3_600_000).toISOString(); |
| 32 | 32 | |
| 33 | 33 | 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 | }); |