Skip to content

perf: prerender homepage / so the hero h1 paints before JS runs#95

Merged
smaramwbc merged 1 commit into
mainfrom
perf-prerender-homepage
May 25, 2026
Merged

perf: prerender homepage / so the hero h1 paints before JS runs#95
smaramwbc merged 1 commit into
mainfrom
perf-prerender-homepage

Conversation

@smaramwbc
Copy link
Copy Markdown
Owner

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 at src/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's renderToString + hydrateRoot. No new framework. No runtime SSR server. Every other route stays a normal SPA.

Pipeline (added to package.json)

  1. build:clienttsc -b && vite build (unchanged)
  2. build:ssr-bundlevite build --ssr src/entry.server.tsx --outDir dist-ssr
  3. build:prerendernode 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 output drops below 10 kB or contains React's "client rendering fallback" sentinel — a regression to client-only never silently ships.
  4. build:servertsc -p tsconfig.server.json (unchanged)

SSR-safety scope (tightly bounded)

  • src/lib/theme.tsxresolvedTheme is now ResolvedTheme | null. 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 (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). Dev workflow is unchanged.
  • The rest of the tree was already SSR-safe — typeof window guards in widget-context, useEffect-only access in ChatWidget / 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> (Lighthouse largest-contentful-paint-element audit returned null because 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

$ curl -s https://www.statewave.ai/ | grep -oE '<h1[^>]*>[^<]+' | head -1

Before this PR: (no h1 in initial HTML — only <div id="root"></div>)

After this PR:

<h1 class="mt-6 sm:mt-8 text-[clamp(2.25rem,8vw,4.5rem)] font-bold text-theme-primary tracking-[-0.025em] leading-[1.08] break-anywhere">Open-source memory runtime…

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

Asset Size
dist/index.html grew from ~9 kB to ~100 kB (~25 kB gzipped) — includes the full prerendered hero/navbar/footer markup
Entry index-*.js 470 KB raw / 138 KB gz (unchanged)
New entry.server.js (SSR bundle, build-time only, never shipped) 187 KB raw
dist-ssr/ directory auto-deleted at the end of the prerender step

Net 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)

Run Perf LCP FCP TBT
1 0.77 4.0 s 3.9 s 20 ms
2 0.77 3.9 s 3.9 s 10 ms
3 0.83 3.5 s 3.5 s 10 ms
4 0.77 3.9 s 3.9 s 10 ms
5 0.77 4.0 s 3.9 s 20 ms
median 0.77 3.9 s 3.9 s 10 ms

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:

  • Mobile cold LCP consistently under 2.5 s
  • FCP materially improves vs the 3.9 s cold baseline
  • No regression on TBT / CLS / perf score

Test plan

  • npm run build succeeds end-to-end; prerender script writes 92.6 kB of SSR HTML into dist/index.html without tripping the size or fallback guards
  • vite preview of the prerendered build serves the h1 in the initial HTTP response
  • Lighthouse errors-in-console audit against the preview: score 1.0, zero items — no hydration mismatches, no React errors
  • vite dev still works (empty root → createRoot fallback)
  • npm run lint, npm run typecheck, npx vitest run — all green (277 passing + 5 conditionally-skipped SSR smoke tests at tests/entry-server.test.ts that run when dist-ssr/ is present)
  • CI green
  • Vercel preview deploys; homepage looks identical visually on mobile and desktop
  • After merge + production deploy: 5 mobile Lighthouse runs comparing to the baseline above

Rollback plan

The change is purely additive — every step can be reverted independently:

  1. Whole PR rollback: git revert <merge-sha>. No infra/config changes outside the repo.
  2. Keep the entry split, drop just the prerender: remove the build:ssr-bundle and build:prerender steps from package.json's build; delete scripts/prerender.mjs. The site falls back to plain SPA — same behaviour as today.
  3. Suspect a hydration bug: flip main.tsx back to createRoot(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:

  • Prerender additional static routes (/product, /why, etc.) — would extend ROUTES in scripts/prerender.mjs and apply per-route output paths
  • Split the 122 KB Heading-*.js shared chunk (really the framer-motion-bearing common chunk)
  • Inline critical CSS into <style> to remove the CSS request from the FCP path

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).
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
statewave-web Ready Ready Preview, Comment May 25, 2026 11:05am

Request Review

@smaramwbc smaramwbc merged commit e225225 into main May 25, 2026
6 checks passed
@smaramwbc smaramwbc deleted the perf-prerender-homepage branch May 25, 2026 11:07
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.
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant