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

Importing src/web/style.ts, client.ts and asset.ts costs under 50 ms each: today they cost 300–450 ms in every test file that loads the web #8322

Merged opened this

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

Merged

mason/forge:mason/8311-importing-src-web-style-ts-client-ts-and → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/8311-importing-src-web-style-ts-client-ts-and was merged into main as b292736.

Review

Sign in to review this change.
7 files changed +141 −56
src/apps/env_phrase.ts +5 −1
@@ -4,7 +4,11 @@
44 * screen say the same thing without the terminal importing a web module.
55 */
66
7 import { VERIFYING_NOTE, type EnvStatus } from "./runtime.ts";
7 import type { EnvStatus } from "./runtime.ts";
8
9 /** The sentence a serving environment carries while its tests still run;
10 * `runtime.ts` sets it and the studio reads it to word its badge. */
11 export const VERIFYING_NOTE = "serving — still verifying its tests";
812
913 /** The tones an environment's word wears; the web's `Tone` and the terminal's both hold them. */
1014 export type EnvPhraseTone = "default" | "success" | "danger" | "attn";
src/apps/runtime.ts +1 −4
@@ -14,6 +14,7 @@
1414 */
1515
1616 import type { Database } from "bun:sqlite";
17 import { VERIFYING_NOTE } from "./env_phrase.ts";
1718 import { listEgressGrants } from "../core/egress_grants.ts";
1819 import { recordCarried } from "../core/carried.ts";
1920 import { releaseOfCommit } from "../core/release_subject.ts";
@@ -166,10 +167,6 @@ const SLEEP_AFTER_MS = { production: 24 * 3_600_000, preview: 30 * 60_000 };
166167 const SWEEP_EVERY_MS = 60_000;
167168 const BUSY_GRACE_MS = 45 * 60_000;
168169
169 /** The sentence a serving environment carries while its tests still run;
170 * the studio reads the same constant to word its badge. */
171 export const VERIFYING_NOTE = "serving — still verifying its tests";
172
173170 /** What a finished deploy's summary ends with: the studio words its green
174171 * from these, so a pull says whether its tests ran (`words.ts`). */
175172 export const TESTS_PASSED_TAIL = " · its tests passed";
outdated

Two homes for one constant. Point the remaining importers at env_phrase.ts and drop this re-export, or document why it stays.

src/web/pages/board.ts +3 −48
@@ -1014,51 +1014,6 @@ function today(ctx: Ctx, scope: BoardScope): Html {
10141014 );
10151015 }
10161016
1017 // ---- style ----
1018
1019 /**
1020 * The board (docs/DESIGN.md §5): the one layout no other component draws —
1021 * four live regions that fill a second monitor, each scrolling on its own,
1022 * one key to show one. Everything inside them is §5's: rows in a list card,
1023 * badges, meters, kbd. Assembled into the served stylesheet by
1024 * `web/style.ts`, last in the `components` layer. The spend tables
1025 * (`spend.ts`) wear §5's table and stack, with no rules here.
1026 */
1027 export const boardCss = /* css */ `
1028 @layer components {
1029 .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; }
1030 .board > header, .board > .stack { padding: 12px var(--pad) 0; }
1031 .board > #notes { padding: 8px var(--pad) 0; }
1032 #notes ul { list-style: none; margin: 4px 0 0; padding: 0; max-height: 30dvh; overflow: auto; }
1033 #notes li > * { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 44px; overflow-wrap: anywhere; }
1034 #notes li > a { display: block; padding-block: 12px; min-height: 0; }
1035 .board #board-live { display: inline-flex; align-items: center; }
1036 .board #board-clock { min-width: 5ch; text-align: right; }
1037 .board [data-started] { min-width: 7ch; text-align: right; }
1038 .board-grid { flex: 1; display: grid; gap: var(--pad); padding: 16px var(--pad) var(--pad); min-height: 0; }
1039 .board-region { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
1040 .board-region > h2 { display: flex; align-items: center; gap: 8px; margin: 0; }
1041 .board-region[data-fresh] > h2 { color: var(--accent); }
1042 .board-region .row-lead img { display: block; border: 1px solid var(--border); border-radius: 4px; object-fit: cover; object-position: top; }
1043 .board-region .row-main > meter { margin-top: 4px; }
1044 .board [data-window] > meter { width: 56px; }
1045
1046 /* Wide: four regions fill the window, each list scrolling on its own. */
1047 @media (min-width: 1100px) {
1048 .board { height: 100dvh; min-height: 0; }
1049 .board-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); }
1050 .board-region > .card { min-height: 0; overflow: auto; scrollbar-width: thin; }
1051 .board[data-focus] .board-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
1052 }
1053 /* One region, full bleed. */
1054 .board[data-focus] .board-region { display: none; }
1055 .board[data-focus="working"] #working, .board[data-focus="landed"] #landed,
1056 .board[data-focus="decided"] #decided, .board[data-focus="today"] #today { display: flex; }
1057 /* Read from across the room: one region, or the wall mode, at a larger
1058 scale — the same rows, every size in proportion. Nobody is at the keys. */
1059 .board[data-focus] .board-region > * { zoom: 1.3; }
1060 .board[data-wall] .board-region > *, .board[data-wall] > .stack { zoom: 1.4; }
1061 .board[data-wall] { cursor: none; }
1062 .board[data-wall] #notes :is(.input, .btn) { display: none; }
1063 }
1064 `;
1017 // The board's stylesheet lives in `board_style.ts`, so `web/style.ts` can
1018 // read it without loading this page's data graph.
1019 export { boardCss } from "./board_style.ts";
src/web/pages/board_style.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * The board (docs/DESIGN.md §5): the one layout no other component draws —
3 * four live regions that fill a second monitor, each scrolling on its own,
4 * one key to show one. Everything inside them is §5's: rows in a list card,
5 * badges, meters, kbd. Assembled into the served stylesheet by
6 * `web/style.ts`, last in the `components` layer. The spend tables
7 * (`spend.ts`) wear §5's table and stack, with no rules here.
8 */
9 export const boardCss = /* css */ `
10 @layer components {
11 .board { --pad: clamp(16px, 2.2vw, 32px); display: flex; flex-direction: column; min-height: 100dvh; }
12 .board > header, .board > .stack { padding: 12px var(--pad) 0; }
13 .board > #notes { padding: 8px var(--pad) 0; }
14 #notes ul { list-style: none; margin: 4px 0 0; padding: 0; max-height: 30dvh; overflow: auto; }
15 #notes li > * { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; min-height: 44px; overflow-wrap: anywhere; }
16 #notes li > a { display: block; padding-block: 12px; min-height: 0; }
17 .board #board-live { display: inline-flex; align-items: center; }
18 .board #board-clock { min-width: 5ch; text-align: right; }
19 .board [data-started] { min-width: 7ch; text-align: right; }
20 .board-grid { flex: 1; display: grid; gap: var(--pad); padding: 16px var(--pad) var(--pad); min-height: 0; }
21 .board-region { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
22 .board-region > h2 { display: flex; align-items: center; gap: 8px; margin: 0; }
23 .board-region[data-fresh] > h2 { color: var(--accent); }
24 .board-region .row-lead img { display: block; border: 1px solid var(--border); border-radius: 4px; object-fit: cover; object-position: top; }
25 .board-region .row-main > meter { margin-top: 4px; }
26 .board [data-window] > meter { width: 56px; }
27
28 /* Wide: four regions fill the window, each list scrolling on its own. */
29 @media (min-width: 1100px) {
30 .board { height: 100dvh; min-height: 0; }
31 .board-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: repeat(2, minmax(0, 1fr)); }
32 .board-region > .card { min-height: 0; overflow: auto; scrollbar-width: thin; }
33 .board[data-focus] .board-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
34 }
35 /* One region, full bleed. */
36 .board[data-focus] .board-region { display: none; }
37 .board[data-focus="working"] #working, .board[data-focus="landed"] #landed,
38 .board[data-focus="decided"] #decided, .board[data-focus="today"] #today { display: flex; }
39 /* Read from across the room: one region, or the wall mode, at a larger
40 scale — the same rows, every size in proportion. Nobody is at the keys. */
41 .board[data-focus] .board-region > * { zoom: 1.3; }
42 .board[data-wall] .board-region > *, .board[data-wall] > .stack { zoom: 1.4; }
43 .board[data-wall] { cursor: none; }
44 .board[data-wall] #notes :is(.input, .btn) { display: none; }
45 }
46 `;
src/web/style.ts +2 −2
@@ -5,7 +5,7 @@
55 * base → constants, type scale, focus, motion, the page shell
66 * components → the component vocabulary (docs/DESIGN.md is its contract),
77 * plus two surfaces with a vocabulary of their own: the
8 * studio (`studio/style.ts`) and the board (`pages/board.ts`)
8 * studio (`studio/style.ts`) and the board (`pages/board_style.ts`)
99 * content → prose, code, diffs, and the furniture that frames them
1010 * utilities → the few one-liners pages are allowed to reach for
1111 *
@@ -23,7 +23,7 @@
2323 */
2424
2525 import { swatchCss, themeCss } from "../design/themes.ts";
26 import { boardCss } from "./pages/board.ts";
26 import { boardCss } from "./pages/board_style.ts";
2727 import { base, layers, reset, utilities } from "./style/base.ts";
2828 import { components } from "./style/components.ts";
2929 import { content } from "./style/content.ts";
src/web/ui.ts +1 −1
@@ -10,7 +10,7 @@
1010 * reaches for an inline `style` attribute, because the CSP forbids it.
1111 */
1212
13 import { type EnvState, type EnvStatus } from "../apps/runtime.ts";
13 import type { EnvState, EnvStatus } from "../apps/runtime.ts";
1414 import type { PreviewData } from "../apps/data.ts";
1515 import type { Mark } from "../core/marks.ts";
1616 import type { RunKind, RunState } from "../core/runs.ts";
test/web-import-cost.test.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * The web's static modules are cheap to import (#8311). `style.ts`,
3 * `client.ts` and `asset.ts` used to cost 300–450 ms each, not to transpile
4 * their template literals but because each reached the whole app graph: the
5 * stylesheet read the board's rules from the board page (core, actions, db),
6 * and `ui.ts` reached `apps/runtime.ts` twice — once through an import that
7 * named only types, which `verbatimModuleSyntax` keeps as a side-effect
8 * import, and once for one string constant. Every test file that starts a
9 * server paid that before its first test.
10 *
11 * Each module is imported in a fresh `bun` process and the modules it loaded
12 * are read back: none of the three may load the app's data graph again. The
13 * guard is the module list, not a clock — a wall-clock bound flakes under the
14 * parallel check; a time budget per test belongs to #1242.
15 */

Remove BUDGET_MS and the expect(cost.ms).toBeLessThan(BUDGET_MS) assertion. A wall-clock bound under a parallel full check is a flake risk. Keep the module-list assertion, which is deterministic.

16
17 import { describe, expect, test } from "bun:test";
18 import { createHash } from "node:crypto";
19 import { join } from "node:path";
20 import { boardCss as boardPageCss } from "../src/web/pages/board.ts";
21 import { boardCss } from "../src/web/pages/board_style.ts";
22 import { assetResponse, shellAsset, stylesheetAsset, stylesheetResponse } from "../src/web/asset.ts";
23 import { css } from "../src/web/style.ts";
24 import { shellJs } from "../src/web/client.ts";
25
26 const ROOT = join(import.meta.dir, "..");
27 const MODULES = ["src/web/style.ts", "src/web/client.ts", "src/web/asset.ts"];
28
29 /** Modules the static web must never pull in: the data graph and the pages.
30 * Of `src/apps/` only `env_phrase.ts` (plain words, type-only imports) is allowed, so
31 * any path into the apps graph — `runtime.ts`, `data.ts`, … — fails. */
32 const HEAVY = [
33 /^src\/core\//,
34 /^src\/db\//,
35 /^src\/apps\/(?!env_phrase\.ts$)/,
36 /^src\/web\/pages\/board\.ts$/,
37 /^src\/web\/layout\.ts$/,
38 ];
39
40 function loadedModules(module: string): string[] {
41 const script = `
42 await import(${JSON.stringify(`./${module}`)});
43 const root = process.cwd() + "/";
44 const modules = Object.keys(require.cache).filter((k) => k.startsWith(root)).map((k) => k.slice(root.length));
45 console.log(JSON.stringify(modules));
46 `;
47 const run = Bun.spawnSync([process.execPath, "-e", script], { cwd: ROOT, stdout: "pipe", stderr: "pipe" });
48 if (run.exitCode !== 0) throw new Error(`importing ${module} failed: ${run.stderr.toString()}`);
49 return JSON.parse(run.stdout.toString().trim().split("\n").at(-1)!) as string[];
50 }
51
52 describe("importing the web's static modules", () => {
53 for (const module of MODULES) {
54 test(`${module} imports without the app's data graph`, () => {
55 const modules = loadedModules(module);
56 expect(modules).toContain(module);
57 expect(modules.filter((m) => HEAVY.some((re) => re.test(m)))).toEqual([]);
58 });
59 }
60 });
61
62 describe("the served bytes are the modules' own", () => {
63 const sha256 = (body: string) => createHash("sha256").update(body).digest("hex");
64
65 test("the board page still exports the very rules the stylesheet serves", () => {
66 expect(boardPageCss).toBe(boardCss);
67 expect(css).toContain(boardCss);
68 });
69
70 test("the stylesheet's address and body hash to the assembled sheet", async () => {
71 const asset = stylesheetAsset();
72 expect(asset.etag).toBe(`"${sha256(css)}"`);
73 const body = await stylesheetResponse(new Request(`http://forge.test${asset.path}`)).text();
74 expect(sha256(body)).toBe(sha256(css));
75 });
76
77 test("the shell script's address and body hash to the shell", async () => {
78 const asset = shellAsset();
79 expect(asset.etag).toBe(`"${sha256(shellJs)}"`);
80 const body = await assetResponse(new Request(`http://forge.test${asset.path}`), asset).text();
81 expect(sha256(body)).toBe(sha256(shellJs));
82 });
83 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs