fix: eliminate CLS 0.975 via static page shell in index.html (#432)#433
Merged
rdmueller merged 2 commits intoApr 14, 2026
Merged
Conversation
…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>
Contributor
|
Caution Review failedPull request was closed or merged during review WalkthroughDie Ä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 Changes
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
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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.
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>
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 #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
Expected after deploy
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes