8.7 KiB
Plan 006: Stop re-mapping the font list per combobox instance; defer the font-metadata payload
Executor instructions: Follow step by step. Confirm each verification. Honor "STOP conditions". When done, update the status row in
plans/README.md.Drift check (run first):
git diff --stat 73daf22b2..HEAD -- apps/web/src/components/typography/combobox.tsx apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx packages/fonts/src/index.tsIf any changed, compare "Current state" excerpts against live code; on mismatch, STOP.
Status
- Priority: P2
- Effort: S
- Risk: LOW
- Depends on: none
- Category: perf
- Planned at: commit
73daf22b2, 2026-07-08
Why this matters
packages/fonts/src/webfontlist.json is a 476 KB metadata file (7,526
lines). packages/fonts/src/index.ts:7 imports it statically and derives
fontList (~506 entries). The typography combobox
(apps/web/src/components/typography/combobox.tsx:11-24) maps that full list
into option objects inside a useMemo(..., []) — which memoizes per component
instance, not globally. Two instances render (body + heading font pickers), so
the 506-entry map runs twice at mount for identical output.
Two honest, bounded improvements:
- Guaranteed win: hoist the options mapping to module scope so it runs once per process regardless of instance count. Small but free and correct.
- Payload win (conditional): the font metadata only matters where the font
picker renders (the builder typography panel). Deferring it keeps it out of
any route/panel that never opens the picker — if the bundler isn't already
forced to load it there for another reason (PDF font registration also
consumes
@reactive-resume/fonts). Step 3 measures before changing, and reports back rather than forcing a change that yields nothing.
Scope this realistically: #1 is certain; #2 is worth doing only if the build shows the payload actually lands on a route that doesn't need it.
Current state
-
apps/web/src/components/typography/combobox.tsx:1-27—FontFamilyCombobox:import { fontList, getFont, getFontDisplayName, getFontSearchKeywords, sortFontWeights } from "@reactive-resume/fonts"; // ... export function FontFamilyCombobox({ className, ...props }: FontFamilyComboboxProps) { const options = useMemo(() => { return fontList.map((font) => ({ value: font.family, keywords: getFontSearchKeywords(font.family), label: <FontDisplay family={font.family} label={getFontDisplayName(font.family)} type={font.type} url={"preview" in font ? font.preview : undefined} />, })); }, []); return <Combobox {...props} options={options} className={cn("w-full", className)} />; }The
labelis JSX (<FontDisplay .../>), so the mapped array holds React elements — hoisting must keep the elements' props static (they are: derived purely fromfont). Nothing in the map depends on component props. -
Single usage site:
apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx:14imports and rendersFontFamilyCombobox(line ~106) andFontWeightCombobox(line ~133). No other file uses these components. -
packages/fonts/src/index.ts— statically importswebfontlist.json(line 7) and exportsfontList,getFont,getFontDisplayName,getFontSearchKeywords,sortFontWeights(used by the combobox) plus registration helpers used bypackages/pdf(packages/pdf/src/hooks/use-register-fonts.ts). -
Bundler note: TanStack Router has
autoCodeSplitting; per prior analysis the font metadata resolves into thepdf-documentchunk, not the main entry. This is exactly why Step 3 measures before assuming a payload win exists.
Commands you will need
| Purpose | Command | Expected |
|---|---|---|
| Typecheck (web) | pnpm --filter web typecheck |
exit 0 |
| Web tests | pnpm --filter web test -- typography |
all pass (or none) |
| Build (Step 3) | pnpm --filter web build |
exit 0; chunk sizes printed |
(Do NOT run pnpm check.)
Scope
In scope:
apps/web/src/components/typography/combobox.tsxapps/web/src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx(only if Step 3 justifies lazy-loading)plans/README.md(status row)
Out of scope:
packages/fonts/*— do not restructure the fonts package or change what@reactive-resume/fontsexports. The win here is on the web-app consumer side.packages/pdffont registration — unrelated consumer.- The 476 KB JSON content itself.
Git workflow
- Branch:
advisor/006-font-combobox - Commit:
perf(web): compute font-picker options once at module scope - Do NOT push or open a PR unless instructed.
Steps
Step 1: Hoist the font options to module scope
In combobox.tsx, move the fontList.map(...) computation out of the component
into a module-level constant (e.g. const FONT_FAMILY_OPTIONS = fontList.map(...))
and pass that constant to <Combobox options={FONT_FAMILY_OPTIONS} />. Remove
the now-unnecessary useMemo for FontFamilyCombobox (its dep array is empty
and the value is now module-constant). Leave FontWeightCombobox unchanged —
its options depend on the fontFamily prop and must stay per-instance.
Verify: pnpm --filter web typecheck → exit 0.
Step 2: Confirm the picker still renders
If a typography test exists, run it; otherwise this is verified by typecheck +
the build in Step 3. Ensure FontDisplay still receives the same props.
Verify: pnpm --filter web test -- typography → passes or reports no tests.
Step 3: Measure, then decide on deferral (investigate — may end in a report)
Run pnpm --filter web build and note the chunk that contains the font
metadata (search the build output / apps/web/dist/assets for the large
chunk; the 476 KB JSON shows up as a ~400–500 KB contribution). Determine
whether that chunk loads on a route that does not need the font picker
(e.g. the dashboard or a public resume page).
- If the font metadata already only loads with the PDF/preview chunk that the
builder needs anyway → deferring the combobox yields ~nothing. STOP and
report this finding in
plans/README.md's status note; do not add lazy loading. Step 1 stands on its own as the deliverable. - If the font metadata loads on a route with no font picker → wrap
FontFamilyCombobox/FontWeightComboboxat thetypography.tsxusage site inReact.lazy+Suspense(import the combobox module via dynamicimport()), so@reactive-resume/fontsis fetched only when the typography panel mounts. Keep the fallback minimal (the existing field skeleton or a small spinner).
Verify: pnpm --filter web build → exit 0; record the before/after chunk
observation in the PR description.
Test plan
- Step 1 is behavior-preserving; typecheck + build are the gates.
- If Step 3 adds lazy loading, manually confirm (or via an existing e2e that opens the typography panel) that the font picker still populates.
- No new unit test is required for a pure hoist; if
typography.tsxgains a Suspense boundary, ensure any existing builder e2e still passes.
Done criteria
Machine-checkable. ALL must hold:
grep -n "FONT_FAMILY_OPTIONS\|const .*= fontList.map" apps/web/src/components/typography/combobox.tsxshows a module-scope constantFontFamilyComboboxno longer wraps the family options inuseMemopnpm --filter web typecheckexits 0pnpm --filter web buildexits 0git status --porcelainlists only in-scope files +plans/README.mdplans/README.mdstatus row for 006 updated (note whether Step 3 added lazy loading or reported it unnecessary)
STOP conditions
Stop and report if:
- Hoisting breaks the
FontDisplaylabel rendering (elements need per-instance data) — re-check; the current props are purelyfont-derived, so this should not happen. If it does, the component drifted. - Step 3's measurement is ambiguous or the build doesn't surface chunk sizes — report what you observed rather than guessing; do not add lazy loading on a hunch.
Maintenance notes
- This is the lowest-leverage item in the current batch; Step 1 is the sure thing. Do not over-engineer a fonts-package split — that has broad blast radius (PDF registration depends on the same exports) and isn't justified by the payload analysis.
- If the fonts list ever grows substantially or becomes user-configurable, revisit a proper lazy/virtualized font source.