perf: prerender homepage / so the hero h1 paints before JS runs#95
Merged
Conversation
Mobile Lighthouse on prod showed a bimodal LCP — ~2.6s on a warm Vercel
edge, ~3.9s on a cold edge — with FCP equal to LCP in every run. That's
the signature of an empty SPA root: the browser has nothing meaningful
to paint until the entry bundle downloads, parses, and React mounts.
The hero <h1> is the LCP element on mobile (already confirmed by an
existing in-source comment) and nothing larger paints first because
nothing visible exists in the initial document at all.
Smallest reliable solution that gets the h1 into the initial response:
build-time prerender of just `/` using Vite's first-class SSR support
plus React 19's renderToString + hydrateRoot. No new framework, no
runtime SSR server. Every other route stays a normal SPA. Rollback is
documented at the top of scripts/prerender.mjs and is purely additive
(drop the new build steps, revert main.tsx to createRoot).
Pipeline (package.json):
1. build:client — `tsc -b && vite build` (unchanged behaviour)
2. build:ssr-bundle — `vite build --ssr src/entry.server.tsx
--outDir dist-ssr`
3. build:prerender — `node scripts/prerender.mjs` imports the SSR
bundle, calls render('/'), injects the result
into the empty <div id="root"></div>
placeholder in dist/index.html, then deletes
dist-ssr/. Belt-and-braces: throws if the
output drops below 10 kB or contains React's
"client rendering fallback" sentinel, so a
regression to client-only never silently
ships.
4. build:server — `tsc -p tsconfig.server.json` (unchanged)
SSR-safety changes were tightly scoped:
* src/lib/theme.tsx — `resolvedTheme` is now `ResolvedTheme | null`
instead of always-resolved. SSR and the first client render both
produce `null` for hydration parity. A mount effect reads
localStorage + matchMedia and reconciles. The inline <script> in
index.html already sets `data-theme` on <html> before paint, so
CSS-driven components stay visually correct during the null
window. Consumers that read `resolvedTheme` (ChatWidget,
HeroBackground, Logo full-variant) already do `=== 'dark'` checks
that fall through to a sensible default when null.
* src/main.tsx — uses `hydrateRoot` when the root has children
(prod), `createRoot` when empty (vite dev, any non-prerendered
route). Backwards compatible with the dev workflow.
The rest of the tree was already SSR-safe (typeof-window guards in
widget-context, useEffect-only access in ChatWidget / usePageSEO /
HomePage). Logo's icon variant — the only one rendered on /, in the
navbar/footer/chat-widget — is theme-agnostic inline SVG, so no
hydration concerns there.
Verification:
* `vite build` — passes, prerender writes 92.6 kB of SSR HTML into
dist/index.html
* Structural sanity on the output: 1 h1, 1 header / 1 footer / 1
main, balanced <div> tags, canonical = https://www.statewave.ai,
4 JSON-LD blocks (Organization / WebSite / SoftwareApplication /
FAQPage) — unchanged from baseline.
* `vite dev` — root still empty, main.tsx uses createRoot, no
prerender step runs.
* Lighthouse `errors-in-console` audit against `vite preview` of the
prerendered build: score 1.0, zero items. Hydration is clean,
React 19 did not fall back to client-rendering for any subtree.
* Local mobile Lighthouse against `vite preview`: FCP 2.3s, LCP
2.7s, TBT 0ms, perf 0.94. FCP < LCP for the first time — the h1
paints from HTML, something later becomes the largest paint.
Real-world prod numbers will be measured post-merge.
* `npm run lint`, `npm run typecheck`, `npx vitest run` — all green.
277 + 5 skipped tests (the SSR-bundle smoke tests at
tests/entry-server.test.ts auto-skip without dist-ssr present,
run when it is).
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
8 tasks
smaramwbc
added a commit
that referenced
this pull request
May 25, 2026
Follow-up to #95. The full-page prerender shipped 92.6 kB of SSR HTML (102 kB total), which on Lighthouse's mobile throttle (4x CPU, Slow 4G) costs enough parse + style-calc time to offset the "h1-in-HTML" paint win on cold-edge runs. 5-run prod measurement after #95 merged: cold-edge LCP held at 4.0 s median, basically unchanged from the 3.9 s baseline. This PR keeps the prerender, but restricts the SSR'd subtree to exactly what the user can see above the fold on a mobile viewport: header + skip-link + the HeroSection (badge, h1, subheadline, CTAs, "real instance · live data" stripe). Everything else moves behind a `<ClientOnly>` wrapper. Change: * New `src/components/ClientOnly.tsx` — render-after-mount wrapper. Server and the first client render both emit null for the children, so hydration is identity; a post-mount effect flips the flag and the children render on the next tick. * `src/pages/HomePage.tsx` — wrap WhatSection through CTASection in <ClientOnly>. HeroSection stays in the prerender path so the h1 still paints from HTML. * `src/components/Layout.tsx` — wrap Footer, ScrollToTopButton, and ChatWidget in <ClientOnly>. They were already below-the-fold or collapsed; deferring them keeps the SSR tree small. Navbar + <main><Outlet/></main> stay in the prerender. * `scripts/prerender.mjs` — lower the size floor from 10 kB to 5 kB (the new shell is ~11 kB; the React client-fallback template is ~2.6 kB, so 5 kB still catches the regression that floor was there for). The FALLBACK_MARKER check is the real guard. * `tests/entry-server.test.ts` — rewritten to assert on the final `dist/index.html` artifact instead of the intermediate SSR bundle. The previous vitest-imports-the-bundle path was fragile (vitest's transformer broke react-router's StaticRouter); the artifact-based test is more accurate and adds a regression guard that <footer> is NOT prerendered. Bundle / DOM effect (vite build): before (PR #95): dist/index.html 102 kB / 17 kB gz 144 <div>, 1 <h1>, 1 <header>, 1 <footer> SSR injected payload: 92.6 kB after: dist/index.html 21 kB / 5 kB gz (-79%) 13 <div>, 1 <h1>, 1 <header>, 0 <footer> SSR injected payload: 11.4 kB (-88%) Local Lighthouse mobile against `vite preview` of the shrunk build: perf 0.96, FCP 2.1 s, LCP 2.4 s, TBT 0 ms — FCP < LCP and LCP under the 2.5 s "good" threshold locally. Prod measurement to follow. Verification: * Lighthouse `errors-in-console` audit against the preview: score 1.0, zero items — hydration still clean, React 19 did not fall back to client-rendering. * `npm run build` succeeds; prerender writes 11.4 kB of SSR HTML into dist/index.html (was 92.6 kB). * `npx vitest run`: 281 + 1 skipped (4 new tests pass on the rewritten dist-html-based smoke check). * `npm run lint`, `npm run typecheck`: green. * Hero h1, canonical, 4 JSON-LD blocks (Org / WebSite / SoftwareApplication / FAQPage), navbar, skip-to-content all still in the static document. <footer> deliberately not.
5 tasks
smaramwbc
added a commit
that referenced
this pull request
May 25, 2026
… hero External SEO audit (Section 3 #7) asked for visible credibility signals on the homepage so AI systems and search quality raters can see that Statewave is a real, used project. The audit's specific suggestions (GitHub stars / PyPI downloads / Docker Hub pulls) are usage metrics that change daily and would couple the build to external APIs. Substitute the *quality* metrics that already exist as ground truth in the repo and that the deeper ProofSection already shows: unit-test count, eval- assertion count, and the 8/8-vs-2/8 support workflow benchmark. Add a single-line credibility row to HeroSection, between the "Real Statewave instance · live data" stripe and the closing motion container: Proven in CI: 680 unit tests · 55 eval assertions · 8/8 vs 2/8 on the support workflow benchmark The row lives inside the prerendered shell from #95 + #96, so it ships in dist/index.html and is visible to JS-less crawlers / answer engines on the first byte. The same figures continue to be shown with full visual emphasis in the below-the-fold ProofSection. Anti-drift: hoist the figures to a single module-scope `PROOF_STATS` const so HeroSection and ProofSection cite the same source of truth. The existing convention (per the project's "proof figures mirrored across many surfaces" rule) is to recompute and update together when the eval suite or benchmark moves. Bundle / DOM effect: before: dist/index.html 21,006 bytes after: dist/index.html 21,366 bytes (+360 bytes ≈ ~1.5%) No new dependencies, no animations on the new row (paints with the static markup, not on a stagger delay). Verification: * `npm run build` succeeds; the four PROOF_STATS values (680 / 55 / 8/8 / 2/8) appear in dist/index.html in the hero section, after "No mocks — every episode, memory…" * `npx vitest run` — 281 + 1 skipped (no new tests; existing coverage unchanged) * `npm run lint`, `npm run typecheck` — green
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.
Summary
Mobile Lighthouse on production shows a bimodal LCP — ~2.6 s on a warm Vercel edge, ~3.9 s on a cold edge — with FCP equal to LCP in every run. That's the signature of an empty SPA root: the browser has nothing meaningful to paint until the entry bundle downloads, parses, and React mounts. The hero
<h1>is the LCP element (confirmed both by direct measurement and by an existing in-source comment atsrc/pages/HomePage.tsx:165), and nothing larger paints first because nothing visible exists in the initial document at all.The smallest reliable solution that gets the h1 into the initial response: build-time prerender of just
/using Vite's first-class SSR support plus React 19'srenderToString+hydrateRoot. No new framework. No runtime SSR server. Every other route stays a normal SPA.Pipeline (added to
package.json)build:client—tsc -b && vite build(unchanged)build:ssr-bundle—vite build --ssr src/entry.server.tsx --outDir dist-ssrbuild:prerender—node scripts/prerender.mjsimports the SSR bundle, callsrender('/'), injects the result into the empty<div id="root"></div>placeholder indist/index.html, then deletesdist-ssr/. Belt-and-braces: throws if output drops below 10 kB or contains React's "client rendering fallback" sentinel — a regression to client-only never silently ships.build:server—tsc -p tsconfig.server.json(unchanged)SSR-safety scope (tightly bounded)
src/lib/theme.tsx—resolvedThemeis nowResolvedTheme | null. SSR and the first client render both producenullfor hydration parity. A mount effect readslocalStorage+matchMediaand reconciles. The inline<script>inindex.htmlalready setsdata-themeon<html>before paint, so CSS-driven components stay visually correct during the null window. Consumers (ChatWidget,HeroBackground,Logofull-variant) already do=== 'dark'checks that fall through to a sensible default when null.src/main.tsx— useshydrateRootwhen the root has children (prod),createRootwhen empty (vite dev, any non-prerendered route). Dev workflow is unchanged.typeof windowguards inwidget-context, useEffect-only access inChatWidget/usePageSEO/HomePage. The Logo's icon variant — the only one rendered on/(navbar, footer, chat-widget) — is theme-agnostic inline SVG.Confirmed LCP element
Before: the hero
<h1>(Lighthouselargest-contentful-paint-elementaudit returnednullbecause the element only appeared after JS mount, but the in-source comment at HomePage.tsx:165 names it and a developer already optimized away its framer-motion entrance animation for that reason).After (local preview): FCP and LCP are now distinct (FCP 2.3 s < LCP 2.7 s on local mobile Lighthouse), meaning the h1 paints at FCP from the static HTML and a later element becomes the largest paint. Will confirm the new LCP element on prod post-deploy.
HTML snapshot proving h1 is present in the initial document
Before this PR: (no h1 in initial HTML — only
<div id="root"></div>)After this PR:
Structural sanity on prerendered
dist/index.html: 1 h1, 1 header / 1 footer / 1 main, balanced div tags, canonical =https://www.statewave.ai, 4 JSON-LD blocks (Organization / WebSite / SoftwareApplication / FAQPage) — all unchanged from baseline.Build output
dist/index.htmlindex-*.jsentry.server.js(SSR bundle, build-time only, never shipped)dist-ssr/directoryNet mobile payload increase: ~16 kB gz on the HTML, in exchange for not needing the entry bundle to paint first content.
Mobile Lighthouse — baseline (5 fresh runs on current prod, before this PR)
FCP == LCP in every run → confirms the empty-SPA-root pattern this PR fixes.
Mobile Lighthouse — post-merge
Will be appended as a PR comment after merge + Vercel production deploy. Targeting:
Test plan
npm run buildsucceeds end-to-end; prerender script writes 92.6 kB of SSR HTML intodist/index.htmlwithout tripping the size or fallback guardsvite previewof the prerendered build serves the h1 in the initial HTTP responseerrors-in-consoleaudit against the preview: score 1.0, zero items — no hydration mismatches, no React errorsvite devstill works (empty root →createRootfallback)npm run lint,npm run typecheck,npx vitest run— all green (277 passing + 5 conditionally-skipped SSR smoke tests attests/entry-server.test.tsthat run whendist-ssr/is present)Rollback plan
The change is purely additive — every step can be reverted independently:
git revert <merge-sha>. No infra/config changes outside the repo.build:ssr-bundleandbuild:prerendersteps frompackage.json'sbuild; deletescripts/prerender.mjs. The site falls back to plain SPA — same behaviour as today.main.tsxback tocreateRoot(container).render(tree)unconditionally. The site keeps the prerendered HTML for paint but discards-and-re-renders on hydrate (visual flicker, but no broken behaviour).Not in this PR
Per the requested scope, this is a focused performance PR. No blog, no About page, no further content. Future work that could shave additional LCP if needed:
/product,/why, etc.) — would extendROUTESinscripts/prerender.mjsand apply per-route output pathsHeading-*.jsshared chunk (really the framer-motion-bearing common chunk)<style>to remove the CSS request from the FCP path