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

The client-size check gives the same verdict on every run: ceilings keep headroom and a page measures the same bytes twice #12042

Merged opened this

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

Merged

torque/forge:torque/12040-the-client-size-check-gives-the-same-ver → main 2 commits fork 1 thread · 1 unresolved

  • Merged. torque/12040-the-client-size-check-gives-the-same-ver was merged into main as 41c1668.

Review

Sign in to review this change.
5 files changed +153 −41
CLAUDE.md +1 −1
@@ -240,7 +240,7 @@ Read this before touching `src/web/`. Principles live in `docs/DESIGN.md`; these
240240
241241 1. **A spec per component.** Each design-system component has one spec asserting the same list at 390x844 and 1280x800: operable by keyboard alone, a name and role on every control, visible focus, tap targets of 44 px or more, no horizontal page scroll, nothing closed covering the page, right in `dir="rtl"`, contrast in light and dark. A component without its spec fails the check (#12016 owns the helper).
242242 2. **A lint over `src/web/`.** `bun scripts/design-lint.ts` before you push: off-token colours, spacing and z-index, and a full-viewport `position: fixed` box outside the overlay host are refused, each with the rule and the fix in one line. Build a control from its component, never by hand.
243 3. **A byte ceiling per page.** `docs/design/size-budget.json` holds each first page's client JS and CSS in gzip bytes (linked assets plus inline script and style, counted by `src/measure/client_size.ts`). `test/client-size.test.ts` names the page and the bytes over. Lower a ceiling with `FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts`; never raise one without saying why in the pull.
243 3. **A byte ceiling per page.** `docs/design/size-budget.json` holds each first page's client JS and CSS in gzip bytes (linked assets plus inline script and style, counted by `src/measure/client_size.ts`). `test/client-size.test.ts` renders each page twice and refuses counted inline code that differs between renders (a per-request value goes in a data block or an attribute), then names the page, the bytes over and the command that raises the ceiling. `FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts` writes every ceiling as the measure plus 2 %, rounded up to the next 256 B; a pull that grows a page past its ceiling runs it in the same push and says why.
244244
245245 ## Recipes
246246
docs/design/size-budget.json +16 −16
@@ -1,34 +1,34 @@
11 {
22 "/": {
3 "js": 20824,
4 "css": 37990
3 "js": 21248,
4 "css": 38912
55 },
66 "/explore": {
7 "js": 20539,
8 "css": 37990
7 "js": 20992,
8 "css": 38912
99 },
1010 "/map": {
11 "js": 25949,
12 "css": 39440
11 "js": 26624,
12 "css": 40448
1313 },
1414 "/:owner/:name": {
15 "js": 33905,
16 "css": 37990
15 "js": 34816,
16 "css": 38912
1717 },
1818 "/changelog": {
19 "js": 25280,
20 "css": 37990
19 "js": 25856,
20 "css": 38912
2121 },
2222 "/pricing": {
23 "js": 20539,
24 "css": 37990
23 "js": 20992,
24 "css": 38912
2525 },
2626 "/forges/new": {
27 "js": 20770,
28 "css": 37990
27 "js": 21248,
28 "css": 38912
2929 },
3030 "/claim": {
31 "js": 22943,
32 "css": 37990
31 "js": 23552,
32 "css": 38912
3333 }
3434 }
scripts/client-size.ts +10 −2
@@ -3,10 +3,12 @@
33 * judges them against docs/design/size-budget.json (#12018):
44 * bun scripts/client-size.ts https://forge.example [cookie]
55 * The cookie ("name=value") signs the walk in; signed-out pages are walked without it.
6 * Each page is fetched twice; counted inline code that differs between the two is a
7 * finding (#12040). Prints the ceilings `FORGE_SIZE_WRITE=1` would write from this walk.
68 * Exits 1 naming each page and the bytes over.
79 */
810 import { readFileSync } from "node:fs";
9 import { clientSize, sizeFindings, type Ceilings } from "../src/measure/client_size.ts";
11 import { ceilingsFor, clientSize, inlineDrift, sizeFindings, type Ceilings } from "../src/measure/client_size.ts";
1012 import { PAGE_BUDGETS, pagePath } from "../src/perf/page_budget.ts";
1113
1214 const [base, cookie] = process.argv.slice(2);
@@ -19,15 +21,21 @@ const get = (path: string, signedIn: boolean): Promise<Response> =>
1921 fetch(new URL(path, base), { headers: { "accept-encoding": "identity", ...(signedIn && cookie ? { cookie } : {}) } });
2022
2123 const findings: string[] = [];
24 const sizes: Ceilings = {};
2225 for (const b of PAGE_BUDGETS) {
2326 const res = await get(pagePath(b, "alice", "notes"), b.signedOut !== true);
2427 if (res.status !== 200) {
2528 findings.push(`${b.pattern} (${b.page}): answered ${res.status}, not 200`);
2629 continue;
2730 }
28 const size = await clientSize(await res.text(), async (path) => (await get(path, false)).text());
31 const html = await res.text();
32 const again = await get(pagePath(b, "alice", "notes"), b.signedOut !== true);
33 if (again.status === 200) findings.push(...inlineDrift(`${b.pattern} (${b.page})`, html, await again.text()));
34 const size = await clientSize(html, async (path) => (await get(path, false)).text());
35 sizes[b.pattern] = size;
2936 console.log(`${b.pattern}\tjs ${size.js}\tcss ${size.css}`);
3037 findings.push(...sizeFindings(`${b.pattern} (${b.page})`, size, ceilings[b.pattern]));
3138 }
39 console.log(`ceilings with headroom from this walk:\n${JSON.stringify(ceilingsFor(sizes), null, 2)}`);
3240 for (const f of findings) console.error(f);
3341 process.exit(findings.length === 0 ? 0 : 1);
src/measure/client_size.ts +66 −6
@@ -5,9 +5,11 @@
55 * served at those URLs, compressed with gzip level default) plus every
66 * inline `<script>` and `<style>` body. Data blocks (`type=application/json`,
77 * speculation rules) are not code and are left out.
8 * The ceilings live in docs/design/size-budget.json; a pass may lower one
9 * (FORGE_SIZE_WRITE=1 rewrites the file), nothing may raise one without a
10 * note in the pull.
8 * The ceilings live in docs/design/size-budget.json, written by
9 * `FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts` as the measured
10 * bytes plus headroom (#12040, `ceilingFor`), so one landed byte does not red
11 * main; a pull that grows a page past its ceiling raises it in the same push
12 * and says why.
1113 */
1214
1315 export interface ClientSize {
@@ -35,6 +37,31 @@ function linkedAssets(html: string): { scripts: string[]; styles: string[] } {
3537 return { scripts, styles };
3638 }
3739
40 /** The command that rewrites the ceilings from today's measure. */
41 export const SIZE_WRITE_COMMAND = "FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts";
42
43 /** Headroom over the measured bytes a written ceiling keeps: 2 %, then up to a whole block. */
44 const HEADROOM_FRACTION = 0.02;
45 const HEADROOM_BLOCK = 256;
46
47 /**
48 * The ceiling written for `bytes` measured (#12040): bytes × 1.02, rounded up to
49 * the next multiple of 256 B. Integer arithmetic (bytes·102/100, ceiling) so no
50 * float rounding moves a boundary: 1000 → 1020 → 1024; 1004 → 1024.08 → 1280.
51 */
52 export function ceilingFor(bytes: number): number {
53 if (!Number.isInteger(bytes) || bytes < 0) throw new Error(`bytes must be a non-negative integer, got ${bytes}`);
54 const grown = Math.ceil((bytes * (100 + HEADROOM_FRACTION * 100)) / 100);
55 return Math.ceil(grown / HEADROOM_BLOCK) * HEADROOM_BLOCK;

Use an integer percent constant (102) instead of 100 + 0.02*100; it is integer by construction rather than by luck of float multiplication.

56 }
57
58 /** Every page's ceilings with headroom over its measured size. */
59 export function ceilingsFor(sizes: Ceilings): Ceilings {
60 const out: Ceilings = {};
61 for (const [page, s] of Object.entries(sizes)) out[page] = { js: ceilingFor(s.js), css: ceilingFor(s.css) };
62 return out;
63 }
64
3865 /** The bodies of inline scripts (code only) and styles. */
3966 function inlineCode(html: string): { scripts: string[]; styles: string[] } {
4067 const scripts: string[] = [];
@@ -50,6 +77,35 @@ function inlineCode(html: string): { scripts: string[]; styles: string[] } {
5077 return { scripts, styles };
5178 }
5279
80 /** Each counted inline block, labelled by kind and order (`script #2`), to compare renders. */
81 function inlineBlocks(html: string): { label: string; body: string }[] {
82 const inline = inlineCode(html);
83 return [
84 ...inline.scripts.map((body, i) => ({ label: `script #${i + 1}`, body })),
85 ...inline.styles.map((body, i) => ({ label: `style #${i + 1}`, body })),
86 ];
87 }
88
89 /**
90 * What differs in counted code between two renders of one page; empty when the
91 * same. A per-request value (a nonce, a time, an id) inside an inline block makes
92 * the count vary run to run; it belongs in a data block or an attribute.
93 */
94 export function inlineDrift(page: string, first: string, second: string): string[] {
95 const a = inlineBlocks(first);
96 const b = inlineBlocks(second);
97 if (a.length !== b.length) return [`${page}: ${a.length} counted inline blocks on one render, ${b.length} on the next`];
98 const out: string[] = [];
99 for (let i = 0; i < a.length; i++) {
100 if (a[i]!.body === b[i]!.body) continue;
101 let at = 0;
102 while (a[i]!.body[at] === b[i]!.body[at]) at++;
103 const near = (s: string): string => JSON.stringify(s.slice(Math.max(0, at - 40), at + 40));
104 out.push(`${page}: inline ${a[i]!.label} differs between two renders at char ${at}: ${near(a[i]!.body)} vs ${near(b[i]!.body)}`);
105 }
106 return out;
107 }
108
53109 /** The page's client bytes; `fetchText` answers an asset path with its plain body. */
54110 export async function clientSize(html: string, fetchText: (path: string) => Promise<string>): Promise<ClientSize> {
55111 const linked = linkedAssets(html);
@@ -63,12 +119,16 @@ export async function clientSize(html: string, fetchText: (path: string) => Prom
63119 return { js: await sum(linked.scripts, inline.scripts), css: await sum(linked.styles, inline.styles) };
64120 }
65121
66 /** The finding for one page over its ceiling, naming the page and the bytes over; none within. */
122 /**
123 * The finding for one page over its ceiling, naming the page, the bytes over and the
124 * command that raises it; none within.
125 */
67126 export function sizeFindings(page: string, size: ClientSize, ceiling: ClientSize | undefined): string[] {
68 if (ceiling === undefined) return [`${page}: no ceiling in docs/design/size-budget.json (js ${size.js} B, css ${size.css} B gzip)`];
127 if (ceiling === undefined)
128 return [`${page}: no ceiling in docs/design/size-budget.json (js ${size.js} B, css ${size.css} B gzip); write one with \`${SIZE_WRITE_COMMAND}\``];
69129 const out: string[] = [];
70130 for (const kind of ["js", "css"] as const)
71131 if (size[kind] > ceiling[kind])
72 out.push(`${page}: ${kind} ${size[kind]} B gzip, ${size[kind] - ceiling[kind]} B over its ceiling of ${ceiling[kind]} B`);
132 out.push(`${page}: ${kind} ${size[kind]} B gzip, ${size[kind] - ceiling[kind]} B over its ceiling of ${ceiling[kind]} B; raise it in this pull with \`${SIZE_WRITE_COMMAND}\` and say why`);
73133 return out;
74134 }
test/client-size.test.ts +60 −16
@@ -2,7 +2,9 @@
22 * Each first page's client JavaScript and CSS, gzip bytes, stay under the
33 * ceiling in docs/design/size-budget.json (#12018). Same pages as the
44 * page-load budget. FORGE_SIZE_WRITE=1 rewrites the file with today's numbers
5 * (only ever to lower them; raising one needs a note in the pull).
5 * plus headroom (2 %, up to the next 256 B: `ceilingFor`, #12040); raising one
6 * needs a note in the pull. Each page is rendered twice and its counted inline
7 * code must be identical, so the verdict is the same on every run.
68 */
79 import { afterAll, beforeAll, expect, test } from "bun:test";
810 import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -14,7 +16,7 @@ import { createOrg } from "../src/core/orgs.ts";
1416 import { getRepo } from "../src/core/repos.ts";
1517 import { createUser } from "../src/core/users.ts";
1618 import { serve, type Servers } from "../src/http/server.ts";
17 import { clientSize, sizeFindings, type Ceilings } from "../src/measure/client_size.ts";
19 import { ceilingFor, ceilingsFor, clientSize, inlineDrift, sizeFindings, SIZE_WRITE_COMMAND, type Ceilings } from "../src/measure/client_size.ts";
1820 import { PAGE_BUDGETS, pagePath } from "../src/perf/page_budget.ts";
1921 import { mintClaim } from "../src/spawn/claim.ts";
2022 import { quietLogs } from "./helpers/globals.ts";
@@ -72,26 +74,34 @@ afterAll(() => {
7274 rmSync(root, { recursive: true, force: true });
7375 });
7476
75 async function measure(): Promise<Ceilings> {
76 const out: Ceilings = {};
77 async function render(b: (typeof PAGE_BUDGETS)[number]): Promise<string> {
78 const res = await fetch(`${servers.url}${pagePath(b, "alice", "notes")}`, {
79 headers: b.signedOut ? { "accept-encoding": "identity" } : { cookie, "accept-encoding": "identity" },
80 });
81 if (res.status !== 200) throw new Error(`${b.pattern} answered ${res.status}`);
82 return res.text();
83 }
84
85 async function measure(): Promise<{ sizes: Ceilings; drift: string[] }> {
86 const sizes: Ceilings = {};
87 const drift: string[] = [];
7788 for (const b of PAGE_BUDGETS) {
78 const res = await fetch(`${servers.url}${pagePath(b, "alice", "notes")}`, {
79 headers: b.signedOut ? { "accept-encoding": "identity" } : { cookie, "accept-encoding": "identity" },
80 });
81 if (res.status !== 200) throw new Error(`${b.pattern} answered ${res.status}`);
82 out[b.pattern] = await clientSize(await res.text(), async (path) => {
89 const html = await render(b);
90 drift.push(...inlineDrift(`${b.pattern} (${b.page})`, html, await render(b)));
91 sizes[b.pattern] = await clientSize(html, async (path) => {
8392 const asset = await fetch(`${servers.url}${path}`, { headers: { "accept-encoding": "identity" }, redirect: "follow" });
8493 if (asset.status !== 200) throw new Error(`${path} answered ${asset.status}`);
8594 return asset.text();
8695 });
8796 }
88 return out;
97 return { sizes, drift };
8998 }
9099
91 test("no first page's client JavaScript or CSS passes its ceiling", async () => {
92 const sizes = await measure();
100 test("no first page's client JavaScript or CSS passes its ceiling, and each page measures the same twice", async () => {
101 const { sizes, drift } = await measure();
102 expect(drift).toEqual([]);
93103 if (process.env["FORGE_SIZE_WRITE"] === "1") {
94 writeFileSync(FILE, `${JSON.stringify(sizes, null, 2)}\n`);
104 writeFileSync(FILE, `${JSON.stringify(ceilingsFor(sizes), null, 2)}\n`);
95105 return;
96106 }
97107 const ceilings = JSON.parse(readFileSync(FILE, "utf8")) as Ceilings;
@@ -105,11 +115,45 @@ test("the ceiling file names exactly the budgeted pages", () => {
105115 expect(Object.keys(ceilings).sort()).toEqual(PAGE_BUDGETS.map((b) => b.pattern).sort());
106116 });
107117
108 test("a page over its ceiling is named with the bytes over", () => {
118 test("a page over its ceiling is named with the bytes over and the command that raises it", () => {
109119 expect(sizeFindings("/explore (explore)", { js: 1500, css: 900 }, { js: 1000, css: 900 })).toEqual([
110 "/explore (explore): js 1500 B gzip, 500 B over its ceiling of 1000 B",
120 "/explore (explore): js 1500 B gzip, 500 B over its ceiling of 1000 B; raise it in this pull with `FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts` and say why",
111121 ]);
112 expect(sizeFindings("/x (x)", { js: 1, css: 1 }, undefined)[0]).toContain("no ceiling");
122 expect(SIZE_WRITE_COMMAND).toBe("FORGE_SIZE_WRITE=1 bun test test/client-size.test.ts");
123 const none = sizeFindings("/x (x)", { js: 1, css: 1 }, undefined)[0]!;
124 expect(none).toContain("no ceiling");
125 expect(none).toContain(SIZE_WRITE_COMMAND);
126 });
127
128 test("a written ceiling is the measure plus 2 %, rounded up to the next 256 B", () => {
129 // by hand: 1000 × 1.02 = 1020 → 1024; 1004 × 1.02 = 1024.08 → 1280;
130 // 0 → 0; 251 × 1.02 = 256.02 → 512; 250 × 1.02 = 255 → 256;
131 // 20824 × 1.02 = 21240.48 → 21248 (83 × 256).
132 expect(ceilingFor(1000)).toBe(1024);
133 expect(ceilingFor(1004)).toBe(1280);
134 expect(ceilingFor(0)).toBe(0);
135 expect(ceilingFor(250)).toBe(256);
136 expect(ceilingFor(251)).toBe(512);
137 expect(ceilingFor(20824)).toBe(21248);
138 expect(() => ceilingFor(-1)).toThrow();
139 expect(() => ceilingFor(1.5)).toThrow();
140 expect(ceilingsFor({ "/": { js: 1000, css: 1004 } })).toEqual({ "/": { js: 1024, css: 1280 } });
141 for (let n = 0; n < 50_000; n += 7) {
142 const c = ceilingFor(n);
143 expect(c % 256).toBe(0);
144 expect(c).toBeGreaterThanOrEqual(n * 1.02);
145 expect(c - 256).toBeLessThan(n * 1.02);
146 }
147 });
148
149 test("two renders that differ in counted inline code are named by page and block; data blocks may differ", () => {
150 const page = (nonce: string, t: string) =>
151 `<script nonce="${nonce}">let a=1;</script><script type="application/json">{"t":"${t}"}</script><style>b{c:d}</style><script>let t="${t}";</script>`;
152 expect(inlineDrift("/ (home)", page("n1", "x"), page("n2", "x"))).toEqual([]);
153 const d = inlineDrift("/ (home)", page("n1", "10:00"), page("n1", "10:01"));
154 expect(d).toHaveLength(1);
155 expect(d[0]).toStartWith("/ (home): inline script #2 differs");
156 expect(inlineDrift("/ (home)", "<style>a{}</style>", "")).toEqual(["/ (home): 1 counted inline blocks on one render, 0 on the next"]);
113157 });
114158
115159 test("the count is gzip of linked and inline code, and leaves data blocks out", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs