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 #6738
Check · Review · Land · Live — Changes were asked on this head.
scout/forge:scout/6693-a-live-region-marked-data-live-hold-is-n → main 4 commits fork 2 threads · 2 unresolved
2 files
changed +104
−3
src/web/client.ts +6 −3
| @@ -2007,7 +2007,9 @@ export const liveRegionsJs = /* js */ ` | ||
| 2007 | 2007 | var t = /<time\\b[^>]*>[^<]*<\\/time>/g; |
| 2008 | 2008 | return a.replace(t, '') === b.replace(t, ''); |
| 2009 | 2009 | } |
| 2010 | function swap(fresh, ids) { | |
| 2010 | // data-live-hold: a reader inside the region keeps their place. | |
| 2011 | function holdRegion(r) { var b = r.hasAttribute('data-live-hold') && r.getBoundingClientRect(); return b && b.top < 0 && b.bottom > 0; } | |
holdRegion is correct (top<0 && bottom>0, attribute read first), but it has no caller in any page markup, and forge:held on line ~2031 has no listener. Add the consumer and one real data-live-hold region so the behaviour can be seen in the preview. |
||
| 2012 | function swap(fresh, ids, src) { | |
| 2011 | 2013 | ids.forEach(function (id) { |
| 2012 | 2014 | var mine = document.getElementById(id); |
| 2013 | 2015 | var theirs = fresh.getElementById(id); |
| @@ -2026,6 +2028,7 @@ export const liveRegionsJs = /* js */ ` | ||
| 2026 | 2028 | var typed = Array.prototype.some.call(mine.querySelectorAll('input.input, textarea'), function (i) { return i.value !== ''; }); |
| 2027 | 2029 | if (typed) return; |
| 2028 | 2030 | if (same(mine.innerHTML, theirs.innerHTML)) return; |
| 2031 | if (holdRegion(mine)) { delete tags[src]; mine.dispatchEvent(new CustomEvent('forge:held', { bubbles: true, detail: { html: theirs.innerHTML } })); return; } | |
outdated
Held but the etag for this source was already stored, so the next pull gets 304 and the region never catches up. Clear tags for the source here. Also, a test for the return-to-top path. |
||
| 2029 | 2032 | // A disclosure the reader opened stays open across the swap. |
| 2030 | 2033 | var opened = Array.prototype.map.call(mine.querySelectorAll('details[id][open]'), function (d) { return d.id; }); |
| 2031 | 2034 | mine.innerHTML = theirs.innerHTML; |
| @@ -2062,7 +2065,7 @@ export const liveRegionsJs = /* js */ ` | ||
| 2062 | 2065 | holder.innerHTML = text; |
| 2063 | 2066 | var stamped = holder.content.querySelector('[data-etag]'); |
| 2064 | 2067 | if (stamped) tags[src] = '"' + stamped.getAttribute('data-etag') + '"'; |
| 2065 | swap(holder.content, ids); | |
| 2068 | swap(holder.content, ids, src); | |
| 2066 | 2069 | return; |
| 2067 | 2070 | } |
| 2068 | 2071 | // Parse a copy without the page's own style element: the swap only |
| @@ -2076,7 +2079,7 @@ export const liveRegionsJs = /* js */ ` | ||
| 2076 | 2079 | var theirSprite = fresh.querySelector('.sprite'); |
| 2077 | 2080 | var mySprite = document.querySelector('.sprite'); |
| 2078 | 2081 | if (theirSprite && mySprite) mySprite.replaceWith(theirSprite); |
| 2079 | swap(fresh, ids); | |
| 2082 | swap(fresh, ids, src); | |
| 2080 | 2083 | }) |
| 2081 | 2084 | .catch(function () {}); |
| 2082 | 2085 | } |
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 | }); |
Only top<0 is checked, so a region entirely above the viewport also counts as scrolled into. Add && r.getBoundingClientRect().bottom > 0.