Skip to content

fix: eliminate CLS caused by SPA boot on homepage #432

@raifdmueller

Description

@raifdmueller

Problem

Lighthouse CI reports CLS 0.975 on the homepage — one of the worst scores possible. This is the single dominant performance issue (weight 25 in the overall score) and the only thing keeping the homepage at Performance 0.76 instead of ≥ 0.9.

Accessibility is already at 1.0 after #431, so CLS is now the last blocker for a green Lighthouse CI.

Root cause

The site is a Vite SPA. The built dist/index.html ships with:

<body>
  <div id="app"></div>
  <script type="module" src="/assets/index-...js"></script>
</body>

On first paint, #app is an empty <div> with zero height. Total body height at first paint is near zero. Once the JS module loads and runs, website/src/main.js populates #app in one shot:

app.innerHTML = `
  ${renderHeader()}
  <div id="page-content"></div>
  ${renderFooter(APP_VERSION)}
`

Between frame N (empty #app) and frame N+1 (#app populated with header + page-content + footer), the entire visible page appears from nothing. Chrome/Lighthouse records this as a massive layout shift of body > div#app > div#page-content with a shift score of ~0.975 — effectively the full viewport moved one full viewport-height.

Why the previous attempt didn't work

In #431 I added #page-content { min-height: calc(100vh - 12rem) } thinking the issue was the card grid growing inside #page-content. That was the wrong diagnosis. min-height only kicks in after #page-content exists in the DOM, which is after JS has already run. The shift happens before that rule can apply, during the empty-to-populated transition of #app itself.

Why the other perf audits are already fine

Lighthouse confirms:

  • First Contentful Paint: 0.99
  • Largest Contentful Paint: 1.0
  • Total Blocking Time: 1.0
  • Speed Index: 1.0
  • Server response time: 1.0
  • Bootup time, main thread work, DOM size, etc.: all 1.0

Only CLS is failing. Everything else is green.

Fix: bake the page shell into the static HTML

The cleanest fix is to ensure #app is not empty on first paint. Instead of letting JS wipe and replace #app on boot, the static HTML should already contain:

  1. The header (deterministic — no data needed beyond i18n defaults)
  2. A #page-content div sized to the expected viewport
  3. The footer (deterministic)

When JS boots, it enhances/rebinds the existing DOM instead of replacing it. No DOM nodes appear from nothing, so no layout shift.

Implementation plan

Step 1: Pre-render the homepage shell during build

Extend scripts/prerender-routes.js to also produce a "shell variant" of dist/index.html. Unlike the doc routes (where we inject a specific AsciiDoc fragment), the homepage shell injects:

  • A header HTML snippet matching what renderHeader() outputs at build time (English defaults, default theme)
  • <div id=\"page-content\" style=\"min-height: calc(100vh - 4rem)\"></div> — reserves vertical space
  • A footer HTML snippet matching renderFooter(APP_VERSION)

The key insight is that the header/footer are deterministic in English at default theme — they can be serialized at build time.

Step 2: Make main.js enhance instead of replace

Change the bootstrap in website/src/main.js from:

app.innerHTML = `${renderHeader()}<div id=\"page-content\"></div>${renderFooter(...)}`

to:

// If the shell is already in the DOM (pre-rendered), leave it in place
// and only replace what's dynamic (header+footer re-render for language switch,
// page-content for route handling).
if (!document.getElementById('page-content')) {
  app.innerHTML = `${renderHeader()}<div id=\"page-content\"></div>${renderFooter(...)}`
}

This way:

  • Pre-rendered page load: shell is already there. Route handler runs and populates #page-content with the actual route content. No DOM shuffling of the outer structure.
  • Dev server / non-prerendered load: falls back to current behavior.

Step 3: Handle language/theme switches

When the user switches language or theme after boot, the existing applyTranslations() already updates data-i18n attributes in place. The header might need a re-render only if the HTML structure differs between languages — need to verify. Theme is CSS-only.

If the header genuinely needs re-rendering for language switches, use a targeted replacement (document.querySelector('header').outerHTML = renderHeader()) instead of wiping #app.

Step 4: Pre-rendered doc routes also benefit

The current prerender-routes.js injects content into #app for doc routes but the same empty-to-populated problem affects them too on the SPA transition. Changing main.js to be "enhance if already rendered" fixes this for all pre-rendered routes simultaneously.

Expected outcome

  • Homepage CLS: 0.975 → ~0.00 (target ≤ 0.1)
  • Performance: 0.76 → ≥ 0.9 (should exceed the threshold since CLS was the only weighted failure)
  • Lighthouse CI green
  • No user-visible change (SPA UX is identical; crawlers and no-JS fetchers additionally get full HTML instead of an empty shell)

Test plan

  • Build produces dist/index.html with non-empty #app containing header + page-content + footer
  • curl https://llm-coding.github.io/Semantic-Anchors/ | grep -c \"semantic-anchor\\|nav-link\" returns > 0 (content is in the initial HTML)
  • Visual inspection: homepage loads without flicker on slow 3G simulation
  • E2E tests still pass (89/89 unit + all Playwright)
  • Pre-rendered doc routes (/workflow, /about, etc.) also benefit from the main.js "enhance" change
  • Post-deploy Lighthouse CI shows CLS ≤ 0.1 and Performance ≥ 0.9

Alternatives considered

  1. Lower the Lighthouse thresholds — quick fix, hides the real UX problem. Rejected: CLS 0.975 is genuinely bad for users, not just for the CI score.

  2. Delay JS bundle with defer — doesn't help. The empty-to-populated transition still happens; just later.

  3. CSS-only: min-height: 100vh on #app — reserves body height, but the shift inside #app (nothing → header + content + footer appearing) is still counted. Tried in fix: Lighthouse a11y + CLS issues on homepage #431 on #page-content, didn't work.

  4. Full SSR with a framework (Astro, Next.js, etc.) — correct long-term direction but way out of scope for this fix. The static pre-render approach above is ~90% as effective with much less churn.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions