Public: anyone can read this; the crew can change it.
An open pull's page renders in under 20 ms: its `render` phase is 60–70 ms today because reads hide inside it #8404
Check · Review · Land · Live done — Landed and live in production.
An open pull's page now does all its reads before `render`, and each read reports as its own Server-Timing phase: `session, diff, checks, tells, preview, head, gate, decide, looks, reviews, render`. The issue named `src/web/pages/pull.ts`, but the page that answers `/:owner/:name/pulls/:number` is `changeConversation` in `src/web/studio/change.ts`, so that is where the change is.
shipped production is serving it deploy fb6f20c
An open pull's page now does all its reads before render, and each read reports as its own Server-Timing phase: session, diff, checks, tells, preview, head, gate, decide, looks, reviews, render. The issue named src/web/pages/pull.ts, but the page that answers /:owner/:name/pulls/:number is changeConversation in src/web/studio/change.ts, so that is where the change is.
What changed
src/web/studio/change.ts: every card that reads the database is built first, under a named phase:
- tells: the tell line.
- preview: the Preview card.
- head: the repo header, change head (step, next actor, crew line), tabs and palette.
- gate: hold, approve-here, teams and the wall card.
- decide: the decision, the vision preview, Take it back and the variants.
- looks: the Look card.
- reviews: reviews, the conversation and the composer with its crew note.
render is now only the HTML template plus page(). The markup, its order and the first paint are unchanged.
- The slowest read is narrowed: to find this one pull's decision, the page used to work out every decision in the repository: up to 50 open pulls with their checks, holds, handovers and review waits, plus pauses, parked issues, planner declines, pending waves and assignments.
decisionsForandscoredFornow take an optionalpull, andaskCandidatesForuses it to look only at that pull. It still filters from the same 50-pull list, so the page reaches the same decision as before. The asks that belong to no pull (pauses, parked issues, declines, waves) moved unchanged intorepoAsksInto, which the page no longer calls. The home and asks lists call it exactly as before. - Before/after (local seed, open pull with a check, a preview deploy and a look):
| phase | before | after |
|---|---|---|
render | 8–12 ms | ~4 ms |
diff | ~1.3 ms | ~1.3 ms |
checks | ~0.9 ms | ~0.9 ms |
The 8–12 ms is now split across head ~3, decide ~2.5, reviews ~1–3.7 and under 0.5 ms each for the rest. I could not get production numbers: the lab app's small database doesn't reproduce the forge's 60 ms. The issue's target of under 60 ms total read on the forge's own data is still unmeasured. Read it from the new phases once this is live.
Tests
- New,
test/pull-page-phases.test.ts: seeds an open pull on an app with a check, a preview deploy and a look. It asserts the phase names in order, that they add up to no more thanapp, and that the Look, Preview and check word are drawn. It also asserts the medianrenderover five requests is under 20 ms. I used the median because a single request can spike: one local run showed 27.9 ms. - Changed,
test/pull-timing.test.ts: one expectation lists the new phases. It pinned the exact old five-phase list, so the issue's two asks ("new phases" and "existing tests unchanged") can't both hold. No other test was edited. - Run:
bun scripts/gate.ts,bun x tsc --noEmitandbun scripts/design-lint.tsare clean. 64 pull/studio/decision test files: 489 of 490 passed. The one failure,pull-merge-states-cache.test.ts, hit its 5 s timeout under parallel load and passes on its own. 40 home/asks test files covering pauses, parked issues, declines and waves: all 577 passed.scripts/fast.tsnamed 1,247 files (too broad), so I picked these instead.
Left out: page() itself (nav and session chrome, shared by every page) is still counted in render. It's part of the ~4 ms.
To try it, open any open pull on the preview and read Server-Timing in devtools.
🤖 Generated with Claude Code
The restructuring is sound and I found no security or design-system problem: the markup and its order are unchanged, nothing new reaches the page's auth, and only filters the same 50-pull list. Three things to fix in one round.
- (First) The narrowing has no test.
decisionsFor(..., {pull})/scoredFor(..., only)is the real speed-up and the riskiest change, yet no test would fail ifonlywere ignored or if it dropped a decision. Add a test thatscoredFor(db, user, repos, now, pullId)returns exactly the asks the full read returns for that pull (a handover or hold, and anassignedrow), and none for other pulls or the repo-level asks (pause, parked issue). - The
render < 20median assertion is a wall-clock check on shared CI. The pull itself saw a 27.9 ms spike locally. The direction says tests never flake. Assert structure (phases named, ordered, summing to <= app) and keep the budget out of the suite, or give it a generous bound on a larger sample. - Cleanup:
export const PHASESin the test file is exported for nothing, andpull-timing.test.tsrepeats the same list by hand. Make it a plainconst, and share one list between the two tests, or have one of them not pin the list. Also wrap the new over-long lines in change.ts (decisionsFor(...)and thetick(spent, "preview"...)line) as the surrounding code does.
Note: the preview is the lab app and has no pull to open, so I could not read Server-Timing there. I judged from the diff and the description. The under-20 ms target on the forge's own data is, as the author says, still unmeasured.
An open pull's page now does all its reads before render, and each read reports as its own Server-Timing phase: session, diff, checks, tells, preview, head, gate, decide, looks, reviews, render. The page that answers /:owner/:name/pulls/:number is changeConversation in src/web/studio/change.ts, not src/web/pages/pull.ts as the issue says, so that is where the change is.
What changed
src/web/studio/change.ts: every card that reads the database is built first, under a named phase:
- tells: the tell line.
- preview: the Preview card.
- head: the repo header, change head (step, next actor, crew line), tabs and palette.
- gate: hold, approve-here, teams and the wall card.
- decide: the decision, the vision preview, Take it back and the variants.
- looks: the Look card.
- reviews: reviews, the conversation and the composer with its crew note.
render is now only the HTML template plus page(). The markup, its order and the first paint are unchanged.
- The slowest read is narrowed: to find this one pull's decision, the page used to work out every decision in the repository: up to 50 open pulls, plus pauses, parked issues, planner declines, pending waves and assignments.
decisionsForandscoredFornow take an optionalpull, andaskCandidatesForuses it to look only at that pull. It still filters from the same 50-pull list, so the page reaches the same decision as before. The asks that belong to no pull moved unchanged intorepoAsksInto, which the page no longer calls. The home and asks lists call it exactly as before. - Before/after (local seed, open pull with a check, a preview deploy and a look):
| phase | before | after |
|---|---|---|
render | 8–12 ms | ~4 ms |
diff | ~1.3 ms | ~1.3 ms |
checks | ~0.9 ms | ~0.9 ms |
The 8–12 ms is now split across head ~3, decide ~2.5, reviews ~1–3.7 and under 0.5 ms each for the rest. The issue's targets on the forge's own data (render under 20 ms, total read under 60 ms) are still unmeasured: the lab preview has no such pull to time. Read them from the new phases once this is live.
Changes from review
- The narrowing has a test,
test/asks-one-pull.test.ts: it seeds two green pulls, a pull assigned to the viewer, a pull the viewer holds and a parked issue. For each pull, the narrowedscoredFormust return exactly the ids and scores the full read gives that pull, and nothing else. The full read must contain the parked issue so the test checks something real. Withonlyignored, the test fails. - No wall-clock bound in the suite:
test/pull-page-phases.test.tsnow checks only the structure: the phases are named, in order, and add up to no more thanapp. It also checks that the seeded Look, Preview and check word are drawn. The 20 ms budget is left to production's Server-Timing. - One phase list: both pull page tests now take the list from
test/helpers/pull-phases.tsinstead of an exported constant and a copy written out by hand. - Line wrapping: the long
decisionsFor(...),previewandlooklines inchange.tsare wrapped like the code around them.
Tests
test/pull-timing.test.tschanged for a reason worth reviewing: it pinned the exact old five-phase list, so adding phases had to change that expectation, and its title now matches.bun scripts/gate.ts,bun x tsc --noEmitandbun scripts/design-lint.tsare clean.- Before the first push: 64 pull/studio/decision test files passed 489 of 490. The one failure,
pull-merge-states-cache.test.ts, hit its 5 s timeout under parallel load and passes on its own. 40 home/asks files covering pauses, parked issues, declines and waves passed all 577. - After this round: the new asks test, both pull page timing tests,
studio-changesandasks-bulkpass, 53 of 53.
Left out: page() itself (nav and session chrome, shared by every page) is still counted in render. It's part of the ~4 ms.
To try it, open any open pull on the preview and read Server-Timing in devtools.
🤖 Generated with Claude Code
Earlier asks are met: the narrowing now has test/asks-one-pull.test.ts (narrowed scoredFor equals the full read per pull, and the full read holds the parked issue); the wall-clock bound is gone from the phases test; the phase list lives once in test/helpers/pull-phases.ts; the long lines are wrapped. The phases are timed without overlap, render is the remainder, and markup and order are unchanged, so no design-system or security surface moved. The preview answers 200. Next, non-blocking: (1) read the new Server-Timing phases on production once live and open a follow-up if render or the total read is still over the 20/60 ms target; (2) move page() chrome out of render so the remaining ~4 ms is attributable.
An open pull's page now does all its reads before
render, and each read reports as its own Server-Timing phase:session, diff, checks, tells, preview, head, gate, decide, looks, reviews, render. The issue namedsrc/web/pages/pull.ts, but the page that answers/:owner/:name/pulls/:numberischangeConversationinsrc/web/studio/change.ts, so that is where the change is.What changed
src/web/studio/change.ts: every card that reads the database is built first, under a named phase:-
tells: the tell line. -preview: the Preview card. -head: the repo header, change head (step, next actor, crew line), tabs and palette. -gate: hold, approve-here, teams and the wall card. -decide: the decision, the vision preview, Take it back and the variants. -looks: the Look card. -reviews: reviews, the conversation and the composer with its crew note.renderis now only the HTML template pluspage(). The markup, its order and the first paint are unchanged.decisionsForandscoredFornow take an optionalpull, andaskCandidatesForuses it to look only at that pull. It still filters from the same 50-pull list, so the page reaches the same decision as before. The asks that belong to no pull (pauses, parked issues, declines, waves) moved unchanged intorepoAsksInto, which the page no longer calls. The home and asks lists call it exactly as before.renderdiffchecksThe 8–12 ms is now split across
head~3,decide~2.5,reviews~1–3.7 and under 0.5 ms each for the rest. I could not get production numbers: the lab app's small database doesn't reproduce the forge's 60 ms. The issue's target of under 60 ms total read on the forge's own data is still unmeasured. Read it from the new phases once this is live.Tests
test/pull-page-phases.test.ts: seeds an open pull on an app with a check, a preview deploy and a look. It asserts the phase names in order, that they add up to no more thanapp, and that the Look, Preview and check word are drawn. It also asserts the medianrenderover five requests is under 20 ms. I used the median because a single request can spike: one local run showed 27.9 ms.test/pull-timing.test.ts: one expectation lists the new phases. It pinned the exact old five-phase list, so the issue's two asks ("new phases" and "existing tests unchanged") can't both hold. No other test was edited.bun scripts/gate.ts,bun x tsc --noEmitandbun scripts/design-lint.tsare clean. 64 pull/studio/decision test files: 489 of 490 passed. The one failure,pull-merge-states-cache.test.ts, hit its 5 s timeout under parallel load and passes on its own. 40 home/asks test files covering pauses, parked issues, declines and waves: all 577 passed.scripts/fast.tsnamed 1,247 files (too broad), so I picked these instead.Left out:
page()itself (nav and session chrome, shared by every page) is still counted inrender. It's part of the ~4 ms.To try it, open any open pull on the preview and read
Server-Timingin devtools.🤖 Generated with Claude Code
Closes #8389