Public: anyone can read this; the crew can change it.
The layout's unused meta-refresh option is gone, and a test keeps every page off meta refresh #3210
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3195-the-layout-s-unused-meta-refresh-option → main 1 commit fork
4 files
changed +48
−54
docs/STUDIO.md +4 −4
| @@ -408,10 +408,10 @@ so a card that is answered goes and a new one arrives without a reload. | ||
| 408 | 408 | |
| 409 | 409 | ### Live regions |
| 410 | 410 | |
| 411 | A page with a script never reloads itself: the `meta refresh` some pages | |
| 412 | carry is the no-script fallback, written inside `<noscript>` so a browser | |
| 413 | running the script never even schedules it (removing the tag afterwards | |
| 414 | would not cancel a refresh the parser had already set). Every live region | |
| 411 | A page never reloads itself: no page carries a `meta refresh` | |
| 412 | (`test/refresh-meta.test.ts` renders every page in `app.looks` to hold | |
| 413 | it), since a refresh is scheduled the moment the parser meets the tag and | |
| 414 | removing the tag afterwards would not cancel it. Every live region | |
| 415 | 415 | listens to one `EventSource` (`/events`, |
| 416 | 416 | everything the viewer may see) and works like this. (The stream also carries |
| 417 | 417 | `run.log` frames, `{ run, lines, truncated }`, a run's new log lines to those |
src/web/layout.ts −31
| @@ -105,20 +105,6 @@ export interface PageOptions { | ||
| 105 | 105 | turnstile?: boolean; |
| 106 | 106 | /** Extra palette rows for this page's context (a repo's own tabs). */ |
| 107 | 107 | palette?: PaletteItem[]; |
| 108 | /** | |
| 109 | * The JavaScript-free half of a page that changes on its own: a | |
| 110 | * `<meta http-equiv="refresh">`, optionally to somewhere else. Only a page | |
| 111 | * whose content is genuinely still moving should set it — a refresh a | |
| 112 | * reader did not ask for steals their scroll position and their selection. | |
| 113 | * | |
| 114 | * A plain refresh (no `url`) is written inside `<noscript>`: a browser | |
| 115 | * running the page's script never parses it, so the script's stream is | |
| 116 | * the only thing moving the page. Removing the element afterwards would | |
| 117 | * not do — a refresh is scheduled the moment the parser meets the tag, | |
| 118 | * and the timer outlives the element. A refresh that leads somewhere | |
| 119 | * (`url`) is a redirect and stays in force either way. | |
| 120 | */ | |
| 121 | refresh?: { seconds: number; url?: string }; | |
| 122 | 108 | /** |
| 123 | 109 | * No topbar, no footer, no page width: a page that is the whole window |
| 124 | 110 | * (the board). The overlays stay, so the palette is still one chord away. |
| @@ -528,22 +514,6 @@ function pageDescription(options: PageOptions): string { | ||
| 528 | 514 | return line.length <= 200 ? line : `${line.slice(0, 197).trimEnd()}…`; |
| 529 | 515 | } |
| 530 | 516 | |
| 531 | /** | |
| 532 | * The `<meta http-equiv="refresh">` for `PageOptions.refresh`. A redirect | |
| 533 | * (`url`) is a bare tag; a reload is wrapped in `<noscript>`, so only a | |
| 534 | * browser with no script ever schedules it. | |
| 535 | */ | |
| 536 | export function refreshMeta(r: { seconds: number; url?: string }): Html { | |
| 537 | if (r.url !== undefined) | |
| 538 | return html`<meta | |
| 539 | http-equiv="refresh" | |
| 540 | content="${r.seconds}; url=${r.url}" | |
| 541 | />`; | |
| 542 | return html`<noscript | |
| 543 | ><meta http-equiv="refresh" content="${String(r.seconds)}" | |
| 544 | /></noscript>`; | |
| 545 | } | |
| 546 | ||
| 547 | 517 | export function page(options: PageOptions, status = 200): Response { |
| 548 | 518 | // A nonce lets exactly this page's <style> and <script> run while |
| 549 | 519 | // everything else stays forbidden; it is random, or bound to the body when |
| @@ -635,7 +605,6 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 635 | 605 | href="${f.href}" |
| 636 | 606 | />`, |
| 637 | 607 | )} |
| 638 | ${when(options.refresh, refreshMeta)} | |
| 639 | 608 | <meta name="color-scheme" content="light dark" /> |
| 640 | 609 | ${theme |
| 641 | 610 | ? html`<meta name="theme-color" content="${metaThemeColor(theme)}" />` |
test/no-polling.test.ts −1
| @@ -10,7 +10,6 @@ import { Glob } from "bun"; | ||
| 10 | 10 | /** file -> the patterns it may carry, and why */ |
| 11 | 11 | const ALLOWED: Record<string, { allow: string[]; why: string }> = { |
| 12 | 12 | "src/web/sw.ts": { allow: ['http-equiv="refresh"'], why: "the restart page: the forge is down, so there is no /events to hear" }, |
| 13 | "src/web/layout.ts": { allow: ['http-equiv="refresh"'], why: "defines the refresh option and its meta tag; callers are listed here" }, | |
| 14 | 13 | "src/web/client.ts": { allow: ["data-live-every", "setInterval"], why: "the data-live-every mechanism itself, and clocks that only redraw text" }, |
| 15 | 14 | }; |
| 16 | 15 |
test/refresh-meta.test.ts +44 −18
| @@ -1,28 +1,54 @@ | ||
| 1 | 1 | /** |
| 2 | * A page that changes on its own carries a `meta refresh` for browsers with | |
| 3 | * no script. A browser running the page's script must never schedule it: a | |
| 4 | * refresh is set the moment the parser meets the tag, and removing the tag | |
| 5 | * afterwards does not cancel it — which is how a live run page threw away | |
| 6 | * the reader's scroll every five seconds. So the reload is written inside | |
| 7 | * `<noscript>`, and only a redirect (`url`) stands on its own. | |
| 2 | * No forge page carries a `meta refresh`. A refresh is set the moment the | |
| 3 | * parser meets the tag, and removing the tag afterwards does not cancel it | |
| 4 | * — which is how a live run page threw away the reader's scroll every five | |
| 5 | * seconds. Every page now follows the event stream instead (#3175, #3179), | |
| 6 | * and the layout no longer offers the option (#3195). | |
| 8 | 7 | */ |
| 9 | import { describe, expect, test } from "bun:test"; | |
| 10 | import { refreshMeta } from "../src/web/layout.ts"; | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { adminCookie } from "./helpers/admin.ts"; | |
| 11 | 15 | |
| 12 | describe("the refresh meta", () => { | |
| 13 | test("a reload is for browsers without a script only", () => { | |
| 14 | const tag = String(refreshMeta({ seconds: 5 })).replace(/\s+/g, " "); | |
| 15 | expect(tag).toMatch( | |
| 16 | /^<noscript ?><meta http-equiv="refresh" content="5" ?\/><\/noscript>$/, | |
| 17 | ); | |
| 16 | const looks: string[] = (await Bun.file(new URL("../forge.json", import.meta.url)).json()).app.looks; | |
| 17 | ||
| 18 | describe("no page refreshes itself", () => { | |
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | let cookie = ""; | |
| 23 | ||
| 24 | beforeAll(async () => { | |
| 25 | dataDir = await mkdtemp(join(tmpdir(), "forge-refresh-meta-")); | |
| 26 | app = createApp({ dataDir, port: 0 }); | |
| 27 | servers = await serve(app); | |
| 28 | cookie = await adminCookie(app, { username: "ada", email: "[email protected]", password: "refresh-password" }); | |
| 18 | 29 | }); |
| 19 | 30 | |
| 20 | test("a redirect stays in force with or without a script", () => { | |
| 21 | const tag = String(refreshMeta({ seconds: 1, url: "/trevato/forge" })); | |
| 22 | expect(tag).toContain('content="1; url=/trevato/forge"'); | |
| 23 | expect(tag).not.toContain("noscript"); | |
| 31 | afterAll(async () => { | |
| 32 | servers.stop(); | |
| 33 | app.close(); | |
| 34 | await rm(dataDir, { recursive: true, force: true }); | |
| 24 | 35 | }); |
| 25 | 36 | |
| 37 | test("forge.json names pages to look at", () => { | |
| 38 | expect(looks.length).toBeGreaterThan(0); | |
| 39 | }); | |
| 40 | ||
| 41 | for (const path of looks) { | |
| 42 | test(`${path}, signed in or not, has no http-equiv="refresh"`, async () => { | |
| 43 | for (const headers of [{ cookie }, {}]) { | |
| 44 | const res = await fetch(`${servers.url}${path}`, { headers, redirect: "manual" }); | |
| 45 | const body = await res.text(); | |
| 46 | expect(res.status).toBeLessThan(500); | |
| 47 | expect(body).not.toMatch(/http-equiv=["']?refresh/i); | |
| 48 | } | |
| 49 | }); | |
| 50 | } | |
| 51 | ||
| 26 | 52 | test("the page's script no longer reaches for the tag it cannot cancel", async () => { |
| 27 | 53 | const client = await Bun.file( |
| 28 | 54 | new URL("../src/web/client.ts", import.meta.url), |
new above
offline