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

small: /system's refresh does not move the reader's place: a streamed region keeps scroll and focus #11367

Merged opened this

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

Merged

mason/forge:mason/11362-small-system-s-refresh-does-not-move-the → main 4 commits fork 5 threads · 5 unresolved

  • Merged. mason/11362-small-system-s-refresh-does-not-move-the was merged into main as b9c19dd.

Review

Sign in to review this change.
4 files changed +115 −2
docs/web.md +4
@@ -31,3 +31,7 @@ The `Speculation-Rules` header points at one hashed rules file: prefetch, modera
3131 ## Not done
3232
3333 Streaming the body in chunks (the render is one string today); purge by event; the pages other than home, explore and a repository's front page measure the same budgets as a mechanical follow-up. Before/after numbers from #7559 are not producible from this sandbox.
34
35 ## Live regions that keep the reader's place
36
37 A page that patches a region from an event stream (`/system`, `web/system-live.ts`) swaps its `innerHTML`, which resets scroll and drops focus. A region marked `data-live-hold` is swapped by noting the scroll of its `.table-scroll` box and of itself and the focused element (found again by a key: the row's group and first cell, the tag, its place in the row), then restoring all three after the page's own re-sort. When the focused row is gone from the new sample (its process ended), focus falls back to the same control in the first row of the same group, then to the first control of that tag in the region; it is never dropped to the page. A region without the marker is replaced as before. Today only `#sys-procs` carries it.
src/web/pages/system.ts +1 −1
@@ -400,7 +400,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> {
400400 <section class="settings-block"><h2>Processes</h2>
401401 <div class="js-only small muted">Sort by <button class="btn" data-size="sm" type="button" data-sort="cpu" aria-pressed="true"><kbd>c</kbd> CPU</button>
402402 <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div>
403 <div id="sys-procs">${at("sys-procs")}</div></section>
403 <div id="sys-procs" data-live-hold>${at("sys-procs")}</div></section>
404404 ${featuresSection(timePhase(ctx.request, "features", () => featureHealth(ctx.app, reader)))}
405405 ${lagSection(lagMinutes.readings())}
406406 <div id="sys-stalls">${stallSection(stalls, worstStallsTable(worst, nowMono, Date.now()))}</div>
src/web/system-live.ts +39 −1
@@ -52,6 +52,40 @@ export const systemLiveJs = /* js */ `
5252 if ((tag === 'BUTTON' || tag === 'A') && !(t.hasAttribute && t.hasAttribute('data-sort'))) return;
5353 if (e.key === 'c') pick('cpu'); else if (e.key === 'm') pick('mem');
5454 });
55 // A region marked data-live-hold keeps the reader's place across a swap: the scroll of
56 // its table box and of itself, and the focused element, found again by a stable key
57 // (the kind and the row's first cell, the tag, its place among that tag in the row).
outdated

.table-scroll is not in system.ts as far as I can grep. If the table is not wrapped, this scroll restore does nothing; the dead branch needs removing or a real wrapper.

58 // hold() notes the place and returns what puts it back; it is called after sort(),
59 // so a row the sort moved still gets its focus. When that row is gone (its process
outdated

Focus is found by DOM index. After a swap sort() reorders rows, so focus is lost or lands on a different row. Key it on something stable, and restore after sort().

60 // ended), focus falls back to the same control in the first row of the same group,
outdated

el is never used in keyOf. Drop the parameter (and the argument at both call sites).

61 // then to the first of that tag in the region, so it is never dropped to the page.
62 function keyOf(f) {
63 var tr = f.closest('tr'), tb = f.closest('tbody');
64 var same = tr ? Array.prototype.indexOf.call(tr.querySelectorAll(f.tagName), f) : -1;
65 return [tb ? tb.getAttribute('data-kind') : '', tr && tr.cells[0] ? tr.cells[0].textContent : '', f.tagName, same].join('|');
66 }
67 function hold(el) {
68 var box = el.querySelector('.table-scroll'), top = box ? box.scrollTop : 0, left = box ? box.scrollLeft : 0;
69 var own = el.scrollTop, f = document.activeElement;
70 var key = f && f !== el && el.contains(f) ? keyOf(f) : null;
71 return function () {
72 var b = el.querySelector('.table-scroll');
73 if (b) { b.scrollTop = top; b.scrollLeft = left; }
74 el.scrollTop = own;
75 if (key === null) return;
76 var part = key.split('|'), all = el.querySelectorAll(part[2]), group = null, any = null;
77 for (var i = 0; i < all.length; i++) {
78 var c = all[i];
79 if (!c.focus || c.tagName !== part[2]) continue;
80 var k = keyOf(c), p = k.split('|');
81 if (k === key) { c.focus({ preventScroll: true }); return; }
82 if (!group && p[0] === part[0] && p[3] === part[3]) group = c;
83 if (!any) any = c;
84 }
85 var to = group || any;
86 if (to) to.focus({ preventScroll: true });
87 };
88 }
5589 if (typeof EventSource === 'undefined' || /[?&]pause\\b/.test(location.search)) return;
5690 var es = new EventSource('/system/stream');
5791 es.addEventListener('sample', function (e) {
@@ -59,11 +93,15 @@ export const systemLiveJs = /* js */ `
5993 try { data = JSON.parse(e.data); } catch (_) { return; }
6094 var regions = data && data.regions;
6195 if (!regions) return;
96 var restores = [];
6297 for (var id in regions) {
6398 var el = document.getElementById(id);
64 if (el && el.innerHTML !== regions[id]) el.innerHTML = regions[id];
99 if (!el || el.innerHTML === regions[id]) continue;
100 if (el.hasAttribute('data-live-hold')) restores.push(hold(el));
101 el.innerHTML = regions[id];
65102 }
66103 sort();
104 restores.forEach(function (put) { put(); });
67105 });
68106 })();
69107 `;
test/system-stream.test.ts +71
@@ -61,6 +61,11 @@ describe("/system over server-sent events", () => {
6161 // First paint is server-rendered with the latest sample.
6262 expect(body).toContain('id="summary"');
6363 expect(body).toMatch(/id="summary"[^>]*>\s*<[^>]*>CPU /);
64 // The process table is marked to keep the reader's place across a swap.
65 const { Window } = await import("happy-dom");
66 const doc = new Window().document;
67 doc.body.innerHTML = body.replace(/<script[\s\S]*?<\/script>/gi, "");
68 expect(doc.getElementById("sys-procs")?.hasAttribute("data-live-hold")).toBe(true);
6469 });
6570
6671 test("a sample written to the sampler arrives on the stream; a stranger is refused", async () => {
@@ -162,3 +167,69 @@ describe("/system over server-sent events", () => {
162167 await reader.cancel();
163168 });
164169 });
170
171 describe("/system's process table holds the reader's place", () => {
172 const table = (rows: [string, number][]) =>
173 `<div class="table-scroll"><table><tbody data-kind="app" data-cpu="1" data-mem="1"><tr data-group><th>apps</th></tr>` +
outdated

Source-string assertions would pass with a broken swap. Execute the script against a DOM and assert scroll and focus are kept.

174 rows.map(([pid, cpu]) => `<tr data-cpu="${cpu}" data-mem="1"><td>${pid}</td><td><a href="#${pid}">cmd ${pid}</a></td></tr>`).join("") +
175 `</tbody></table></div>`;
176
177 /** The page script run against a DOM; `push` delivers one sample to its stream listener. */
178 async function open(hold: boolean) {
179 const { Window } = await import("happy-dom");
180 const { systemLiveJs } = await import("../src/web/system-live.ts");
181 const win = new Window();
182 const doc = win.document;
183 // happy-dom has no `rows` on a table section; the page script reads it.
184 const section = (doc.createElement("tbody") as unknown as { constructor: { prototype: object } }).constructor.prototype;
185 Object.defineProperty(section, "rows", { configurable: true, get(this: { querySelectorAll(s: string): unknown }) { return this.querySelectorAll(":scope > tr"); } });
186 doc.body.innerHTML = `<div id="sys-procs"${hold ? " data-live-hold" : ""}>${table([["10", 5], ["20", 1]])}</div><div id="summary">old</div>`;
187 let listener: (e: { data: string }) => void = () => {};
188 class FakeSource {
189 addEventListener(_: string, fn: typeof listener) { listener = fn; }
190 }
191 new Function("document", "EventSource", "location", systemLiveJs)(doc, FakeSource, { search: "" });
192 const push = (regions: Record<string, string>) => listener({ data: JSON.stringify({ regions }) });
193 return { doc, push };
194 }
195
outdated

Greps the source for a literal string, so reformatting the template fails it while a broken marker in a different shape passes. Assert the rendered #sys-procs element carries data-live-hold instead, or rely on the DOM test above.

196 test("the stream's payload names the held region", async () => {
197 const { regions } = await import("../src/web/pages/system.ts");
198 expect(Object.keys(regions(createLive().read()))).toContain("sys-procs");
199 });
200
201 test("focus on a row that left falls back to the same control in its group, not to the page", async () => {
202 const { doc, push } = await open(true);
203 (doc.querySelector('a[href="#20"]') as unknown as { focus(): void }).focus();
204 // pid 20's process ended: its row is gone from the next sample.
205 push({ "sys-procs": table([["10", 5], ["30", 1]]) });
206 expect(doc.activeElement?.getAttribute("href")).toBe("#10");
207 });
208
209 test("a held region keeps its scroll and its focus (after the re-sort) while only it and named regions change", async () => {
210 const { doc, push } = await open(true);
211 const box = doc.querySelector("#sys-procs .table-scroll")!;
212 box.scrollTop = 40;
213 box.scrollLeft = 25;
214 (doc.querySelector('a[href="#20"]') as unknown as { focus(): void }).focus();
215 // pid 20 now has the most CPU, so sort() moves its row above pid 10's.
216 push({ "sys-procs": table([["10", 1], ["20", 9]]), summary: "new" });
217 const rows = [...doc.querySelectorAll("#sys-procs tr[data-cpu]")].map((r) => r.getAttribute("data-cpu"));
218 expect(rows).toEqual(["9", "1"]);
219 const after = doc.querySelector("#sys-procs .table-scroll")!;
220 expect(after).not.toBe(box);
221 expect(after.scrollTop).toBe(40);
222 expect(after.scrollLeft).toBe(25);
223 expect(doc.activeElement?.getAttribute("href")).toBe("#20");
224 expect(doc.getElementById("summary")!.innerHTML).toBe("new");
225 });
226
227 test("a region without the marker is replaced as before: no scroll, no focus kept", async () => {
228 const { doc, push } = await open(false);
229 doc.querySelector("#sys-procs .table-scroll")!.scrollTop = 40;
230 (doc.querySelector('a[href="#20"]') as unknown as { focus(): void }).focus();
231 push({ "sys-procs": table([["10", 1], ["20", 9]]) });
232 expect(doc.querySelector("#sys-procs .table-scroll")!.scrollTop).toBe(0);
233 expect(doc.activeElement?.getAttribute("href")).not.toBe("#20");
234 });
235 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs