Skip to content

fix: eliminate CLS 0.975 via static page shell in index.html (#432)#433

Merged
rdmueller merged 2 commits into
LLM-Coding:mainfrom
raifdmueller:fix/cls-prerender-homepage-shell
Apr 14, 2026
Merged

fix: eliminate CLS 0.975 via static page shell in index.html (#432)#433
rdmueller merged 2 commits into
LLM-Coding:mainfrom
raifdmueller:fix/cls-prerender-homepage-shell

Conversation

@raifdmueller

@raifdmueller raifdmueller commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Closes #432.

Problem

Homepage CLS was 0.975 — effectively the entire viewport shifted once during boot — which kept Performance at 0.76 even after every other perf audit was green. The SPA was shipping an empty `<div id="app">` and JS was filling it on boot, so the entire page materialised in one frame.

Full analysis and alternatives considered in #432.

Fix: static skeleton + in-place hydration

Three coordinated changes:

1. `website/index.html` now ships a static shell inside `#app`

```html

...
...
...
\`\`\`

First paint therefore already has the final layout footprint. No empty → populated transition.

2. `website/src/main.js` hydrates in place

New `hydrateShell(app)` detects the skeleton on boot and replaces the two placeholder elements via `outerHTML` with the full `renderHeader()` / `renderFooter()` output. `#page-content` stays in place so route handlers continue to populate it normally — no big innerHTML swap, no new DOM nodes appearing where there was nothing before.

Fallback path preserved for the dev server when the skeleton isn't present.

3. `scripts/prerender-routes.js` injects into `#page-content`

The 9 pre-rendered doc routes used to regex-replace the whole empty `#app` element. Now they inject the doc fragment into the existing `#page-content` div, preserving the shell. A failed regex throws so the build fails fast if `website/index.html` ever loses the skeleton.

Verified locally

  • `dist/index.html` — contains the skeleton, so first paint has the final footprint
  • `dist/workflow/index.html` (and the 8 other pre-rendered routes) — full doc content inside `#page-content`, shell preserved for crawlers
  • Unit tests: 89/89 green
  • Vite build: all 9 routes pre-rendered

Expected after deploy

Metric Before After
CLS 0.975 ~0.00
Performance 0.76 ≥ 0.9
Lighthouse CI red green

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Refaktor
    • Die App-Initialisierungslogik wurde überarbeitet, um eine effizientere Vorkompilierung und schnelleres Seiten-Rendering zu ermöglichen.
    • Die Validierungsprüfungen der erforderlichen App-Struktur wurden verbessert, um Fehler während des Build-Prozesses frühzeitig zu erkennen.
    • Die Handhabung von Kopf- und Fußzeilenkomponenten beim App-Start wurde optimiert und vereinfacht.

…ing#432)

Closes LLM-Coding#432.

The homepage was scoring CLS 0.975 — effectively the entire viewport
shifted once during boot — which kept Performance at 0.76 even after
every other perf audit was green. Lighthouse attributed the shift to
`body > div#app > div#page-content`, with the sub-item "Media element
lacking an explicit size" pointing at the header logo. But the real
cause was the empty-to-populated transition of `#app` itself:

  Frame 1 (first paint): <div id="app"></div>, body height ~0
  Frame 2 (post-JS):     full header + page-content + footer, body ~100vh+

The entire page materialising at once is measured as a single massive
layout shift.

## Fix: static skeleton + in-place hydration

1. website/index.html now ships a static shell inside #app:
     <div id="shell-root"> (100vh min)
       <div id="shell-header" aria-hidden>         10.5rem placeholder
       <div id="page-content">                     flex: 1, min-height
       <div id="shell-footer" aria-hidden>         6rem placeholder
     </div>
   First paint therefore already has the final layout dimensions — the
   header, content area, and footer each occupy their real-world boxes.

2. website/src/main.js `hydrateShell()` detects the skeleton on boot
   and replaces #shell-header / #shell-footer via outerHTML with the
   full renderHeader() / renderFooter() output. #page-content is left
   completely untouched so route handlers continue to populate it the
   same way they do today. No big innerHTML swap, no new DOM nodes
   appearing where there was nothing before.

   If the skeleton is missing (dev server / non-prerendered load) it
   falls back to the previous behaviour so `npm run dev` keeps working.

3. scripts/prerender-routes.js now injects doc fragments INTO the
   existing #page-content div instead of replacing the whole #app
   element. The regex has been tightened to match the shell structure;
   a failed match throws so the build fails fast if website/index.html
   ever loses the skeleton.

## Result

Verified locally:
- dist/index.html contains the skeleton (shell-header + page-content
  + shell-footer) so first paint has the final layout footprint.
- dist/workflow/index.html (and the 8 other pre-rendered routes) have
  the full doc content injected inside #page-content while keeping
  the shell intact for crawlers and claude.ai fetchers.
- Unit tests: 89/89 green.

Expected after deploy:
- CLS 0.975 → ~0.00 (no visible element movement during boot).
- Performance 0.76 → ≥ 0.9 (CLS was the only weighted failure).
- Lighthouse CI green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Caution

Review failed

Pull request was closed or merged during review

Walkthrough

Die Änderungen implementieren eine Shell-Vorrendering-Strategie, um das Cumulative Layout Shift (CLS) Problem zu beheben. Das Skript erzeugt eine statische Shell-Struktur mit Header, Page-Content-Container und Footer. Die HTML-Seite wird mit dieser vorgerenderten Shell ausgeliefert, und main.js verbessert das DOM, anstatt es zu ersetzen.

Changes

Cohort / File(s) Summary
Build-Zeit Shell-Erzeugung
scripts/prerender-routes.js
Hilfsfunktion von buildAppMarkup() zu buildDocContentMarkup() umbenannt; Injektionsziel von #app zu #page-content geändert; Validierung hinzugefügt, um zu prüfen, ob #page-content im statischen HTML existiert.
Statische Shell-Struktur
website/index.html
Leeres <div id="app"></div> durch vorgenerierte Shell ersetzt: #shell-root (Flex-Container), #shell-header und #shell-footer (mit Placeholder-Styling und aria-hidden="true"), sowie leerer #page-content Container mit Mindesthöhe.
Runtime Shell-Hydration
website/src/main.js
Neue hydrateShell(app)-Funktion hinzugefügt, die bestehende #shell-header und #shell-footer Placeholder in-place mit renderHeader() und renderFooter() ersetzt, statt das gesamte app.innerHTML zu überschreiben; Fallback auf bisheriges Verhalten, wenn Placeholder nicht vorhanden.

Sequence Diagram(s)

sequenceDiagram
    participant Build as Build-Zeit
    participant HTML as index.html
    participant Browser as Browser/DOM
    participant Main as main.js

    Build->>HTML: Vorgenerierte Shell mit<br/>Header+PageContent+Footer<br/>in HTML einbetten
    
    Browser->>Browser: Seite rendert mit<br/>Shell-Struktur sichtbar
    
    Browser->>Main: main.js lädt und<br/>initialiert App
    
    Main->>Main: hydrateShell(app)<br/>aufgerufen
    
    Main->>Browser: Shell-Header/Footer<br/>in-place ersetzen
    
    Main->>Browser: `#page-content` mit<br/>Route-Inhalt füllen
Loading
sequenceDiagram
    participant Old as Altes Verhalten
    participant New as Neues Verhalten
    participant Paint as Visuelles Rendering

    Old->>Paint: Frame 1: `#app` leer
    Old->>Paint: Frame 2: `#app` komplett<br/>gefüllt (CLS!)
    Paint->>Paint: Layout-Shift erkannt
    
    New->>Paint: Frame 1: `#app` mit Shell<br/>bereits sichtbar
    New->>Paint: Frame 2: Nur Header/Footer<br/>Placeholder aktualisiert
    Paint->>Paint: Kein signifikanter<br/>Layout-Shift
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Der PR-Titel beschreibt genau die Hauptänderung: Behebung des CLS-Problems durch eine statische Seitenschale in index.html, was dem Kernziel und den Änderungen in den drei Dateien entspricht.
Linked Issues check ✅ Passed Alle Codeanforderungen aus Issue #432 sind erfüllt: statische Shell in index.html, hydrateShell()-Logik in main.js zur Verbesserung statt Ersetzung, und aktualisierte prerender-routes.js zur Injektion in #page-content mit Shell-Validierung.
Out of Scope Changes check ✅ Passed Alle Änderungen sind direkt auf Issue #432 bezogen: Shell-Struktur, Hydration-Logik und Prerendering-Anpassungen. Eine sekundäre ESLint-Regel-Bereinigung ist minimal und unterstützend.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The no-unsanitized/property rule isn't in the project's ESLint config, so
ESLint errored on the disable directive itself. Drop the comment; no rule
flags the innerHTML fallback so no disable is needed.
@rdmueller rdmueller merged commit 16eccc4 into LLM-Coding:main Apr 14, 2026
6 of 7 checks passed
raifdmueller added a commit to raifdmueller/Semantic-Anchors that referenced this pull request Apr 14, 2026
Two follow-ups to LLM-Coding#433 against the persistent CLS 0.975 issue.

## 1. Logo: max-h-24 → h-24 (explicit height)

Lighthouse continued to attribute the layout shift to the header logo
even after LLM-Coding#431 added width="218" height="96" attributes. Root cause:
Tailwind's preflight resets all images to `height: auto`, which
overrides the HTML height attribute. `max-h-24` only caps the height,
it doesn't enforce one — so before the image loads, the rendered
height is `auto` (= 0). When the PNG actually loads, the header grows
by 96px and pushes everything below.

`h-24` (height: 6rem) explicitly sets the height in CSS, overriding
preflight's `auto`. Combined with `w-[218px]` (and the matching
mobile sizes), the browser reserves the exact box from first paint.

## 2. Shell-header / shell-footer dimensions match the rendered ones

The static skeleton in LLM-Coding#433 used min-height: 10.5rem for the header
placeholder, but the real <header> renders at ~8.75rem (140px:
py-3 + h-24 logo + slogan + border). When hydrateShell() replaced
the placeholder, the header shrank by ~28px and #page-content moved
up by the same amount — a small but real shift on top of every other
load contribution.

Tighten the placeholders to match: header 8.75rem, footer 6rem (already
correct), and update #page-content min-height accordingly.

These are smaller contributions than the cards-loading shift, but they
clean up the easily-fixable signal first so the next Lighthouse run
isolates the remaining CLS to the actual hard problem.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

fix: eliminate CLS caused by SPA boot on homepage

2 participants