{{ message }}
Repository navigation
Every page shipped the blog to put a title in the tab - #153
Merged
Merged
Conversation
useSeo() keeps the tab title, description and canonical in step after a
client-side navigation, because navigation never fetches HTML again. To
know what each route is called it imported towns.json, models.json,
service-areas.json and posts.json and composed the list in the browser.
All four landed in the entry chunk. So every page of the site carried
every blog post's body, every car's luggage capacity and the prose for
twelve town pages -- about seventy kilobytes of object literal, run at
module top level before anything on screen could answer a tap -- to end
up with a path, a title and a sentence.
The list is composed at build time now, by scripts/seo-routes.mjs, from
exactly the same four files and the same four helpers. prerender-seo.mjs
reads what it writes instead of composing its own copy, so the titles
written into each page's HTML and the titles useSeo puts in the tab are
one list and cannot drift -- which is what the old comment in useSeo was
reaching for when it said a second list is a second list to forget.
entry chunk 374,166 -> 314,667 bytes (-59.5 KB, -16%)
Every page pays that, not just the home page.
What it did for blocking time, phone at CPU 4x and 1.6 Mbps, median of
eleven runs per arm, two builds measured the same way:
TBT longest task long tasks
before 278ms 152ms 6
after 256ms 144ms 5
Honest about the size of that: the runs scatter 208-388 before and
153-351 after, so a 22ms median is a real direction and not a precise
figure. The 59.5 KB is the certain part.
Verified the output did not move: across all 44 prerendered pages the
only lines that changed are the hashed asset filenames. Not one title or
description differs. A browser check confirms the other half -- clicking
Tariff, Blog and Contact retitles the tab and moves the canonical, which
is the whole job of the file this touched.
Two wrong turns on the way, both measured and both reverted rather than
shipped. content-visibility on the offscreen sections, which cut the
longest layout pass from 73ms to 41ms and did nothing for blocking time,
because the cost was never layout. And the scene drawings: stripping all
176 SVG nodes changed TBT by less than the noise. The page is not slow
because of what it draws. It was slow because of what it carried.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

useSeo()keeps the tab title, description and canonical in step after a client-side navigation, because navigation never fetches HTML again. To know what each route is called it importedtowns.json,models.json,service-areas.jsonandposts.jsonand composed the list in the browser.All four landed in the entry chunk. So every page of the site carried every blog post's body, every car's luggage capacity and the prose for twelve town pages — about 70KB of object literal, run at module top level before anything on screen could answer a tap — to end up with a path, a title and a sentence.
The list is composed at build time now, by
scripts/seo-routes.mjs, from exactly the same four files and the same four helpers.prerender-seo.mjsreads what it writes instead of composing its own copy, so the titles written into each page's HTML and the titlesuseSeoputs in the tab are one list and cannot drift — which is what the old comment inuseSeowas reaching for when it said a second list is a second list to forget.Entry chunk: 374,166 → 314,667 bytes (−59.5 KB, −16%). Every page pays that, not just the home page.
What it did for blocking time
Phone at CPU 4× and 1.6 Mbps, median of eleven runs per arm, two builds measured the same way:
Honest about the size of that: the runs scatter 208–388 before and 153–351 after, so a 22ms median is a real direction and not a precise figure. The 59.5 KB is the certain part.
Verification
Across all 44 prerendered pages the only lines that changed are the hashed asset filenames — not one title or description differs. A browser check covers the other half: clicking Tariff, Blog and Contact retitles the tab and moves the canonical, which is the whole job of the file this touches. All 43 routes crawl clean at desktop and phone; LCP 1720 and CLS 0.000, unchanged.
Two wrong turns, measured and reverted rather than shipped
Both came from misreading a profile, and both are worth recording:
content-visibilityon the offscreen sections. 83% of the home page is below the first screen, so this looked obvious. It cut the longest layout pass from 73ms to 41ms and did nothing for blocking time, because the cost was never layout. Reverted.The thing that found the real cause was a control experiment: with no script at all, TBT is 65ms. That put ~150ms squarely on JavaScript and stopped the search going further down the layout path.
Two profiler readings taken along the way were wrong and are corrected here for anyone reading back: a first run attributed idle gaps to whichever function followed them, inflating one react-router function to 152ms when it is 0.6ms; and a trace's
EvaluateScriptself-time read as 4ms because it excluded the nestedv8.evaluateModule, which was in fact 176ms of the 219ms longest task.🤖 Generated with Claude Code
https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Generated by Claude Code