feat(perf): inline critical CSS + preload critical fonts via prerender post-process#22
Merged
Merged
Conversation
…r post-process Closes the remaining performance gap from the PR #21 PageSpeed follow-up: render-blocking CSS (~1100ms) and undiscovered critical fonts (~400ms). Approach: instead of running beasties as a Vite plugin (which sees only the empty SPA shell), invoke beasties as a Node.js library inside scripts/prerender.mjs, after each route's full DOM is captured by puppeteer. Each of the 637 routes gets its own critical CSS extraction matched to that page's above-the-fold content. Font preloads are injected by the same prerender step. The 4 latin woff2 files (Playfair Display 400, Roboto 400/500/700) are content-hashed by Vite so the URLs change every build; the script extracts them from dist/assets/index-*.css at runtime. scripts/prerender.mjs changes: - New helper extractCriticalFontUrls() parses the built CSS file to find the 4 critical font URLs with their content hashes - New helper buildFontPreloadTags() generates the <link rel=preload> tags (with crossorigin, required by browser spec) - New Beasties instance configured with pruneSource:false (the shared CSS file must remain complete — 637 HTMLs reference it), preload:'swap' (external CSS becomes non-blocking), and inlineThreshold:0 - startServer() now accepts and uses a frozen copy of the original Vite-built index.html as the SPA shell fallback; without this, routes processed after '/' would pick up the already-injected preload tags and produce 8 duplicate preloads instead of 4 - renderOneRoute now: injects font preloads + window globals into the <head>, then runs beasties.process() on the result, then writes the final HTML to disk - If beasties fails on a specific page, log and fall back to the un-processed HTML rather than failing the build package.json: beasties ^0.4.2 added as devDependency (no runtime dependency). Local verification: - 4 font preloads on every route (/, /about, /blog, /ca/blog, slugs) - 18-21 KiB critical CSS inlined per page - CSS swap (preload + onload) pattern present - Shared CSS file unchanged: 90,056 bytes (pruneSource:false) - All 6 blog indexes: 0 errors, 104 links each - 199/199 tests passing Expected outcomes: - Landing LCP: 4.5s → ~2.0-2.5s - Landing Performance: 75 → ~85-90 - Blog slug LCP: 6.5s → ~2.5-3.5s - Blog slug Performance: 66 → ~78-85 - Build time: +~14min on the prerender phase (acceptable for CI) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
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.
Closes the remaining performance gap from PR #21 PageSpeed follow-up.
Approach B from the audit: beasties (Node.js lib) runs inside
scripts/prerender.mjsafter each route's full DOM is captured by puppeteer, so each of the 637 routes gets its own critical CSS extraction matched to that page's above-the-fold content. Font preload tags are injected by the same step, with the content-hashed woff2 filenames extracted from the built CSS at runtime.What changed
extractCriticalFontUrls()— readsdist/assets/index-*.cssat runtime to find the 4 content-hashed font URLs (Playfair Display 400, Roboto 400/500/700 latin); regex handles Vite's-normal-naming patternbuildFontPreloadTags()— generates<link rel="preload" as="font" type="font/woff2" crossorigin>tagsstartServer(originalIndexHtml)— takes a frozen snapshot of the Vite-builtindex.htmlas the SPA shell; without this, routes processed after/would pick up already-injected preload tags and produce 8 duplicates instead of 4Beastiesinstance withpruneSource: false(all 637 HTMLs share one CSS file),preload: 'swap'(non-blocking CSS),inlineThreshold: 0renderOneRoute— injects font preloads + window globals →beasties.process()→ writes to disk; beasties failures fall back to un-processed HTMLLocal verification
Expected outcomes
Risks