From 7062bfc123c0ab119f514ca2d878a2d6e9a32aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=7BAI=7Df=20D=2E=20M=C3=BCller?= Date: Tue, 14 Apr 2026 10:42:24 +0200 Subject: [PATCH 1/2] fix: eliminate CLS 0.975 via static page shell in index.html (#432) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #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):
, 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:
(100vh min)
10.5rem placeholder
flex: 1, min-height 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) --- scripts/prerender-routes.js | 36 ++++++++++++++++++++-------------- website/index.html | 15 +++++++++++++- website/src/main.js | 39 ++++++++++++++++++++++++++++++++----- 3 files changed, 69 insertions(+), 21 deletions(-) diff --git a/scripts/prerender-routes.js b/scripts/prerender-routes.js index a532d9ea..476145b0 100644 --- a/scripts/prerender-routes.js +++ b/scripts/prerender-routes.js @@ -132,18 +132,16 @@ function escapeHtml(str) { } /** - * Build the pre-populated markup that goes inside
. - * Mirrors the layout produced at runtime by renderHeader() + renderDocPage() - * + renderFooter() in website/src/main.js, but statically — so crawlers see - * real content in the initial HTML response. + * Wrap the doc fragment in the structure that website/src/components/doc-page.js + * produces at runtime: a centered article container with a #doc-content div. + * The content is injected into the shell's #page-content div so crawlers and + * non-JS fetchers see real content in the initial HTML response. */ -function buildAppMarkup(fragmentHtml) { +function buildDocContentMarkup(fragmentHtml) { return ` -
-
-
${fragmentHtml}
-
-
+
+
${fragmentHtml}
+
` } @@ -187,11 +185,19 @@ function prerenderRoute(shell, route) { `` ) - // Inject pre-rendered content into #app - html = html.replace( - /\s*<\/div>/, - `
${buildAppMarkup(fragment)}
` - ) + // Inject pre-rendered content into the static shell's #page-content div. + // The shell (set up in website/index.html and preserved through vite build) + // contains: + //
+ // We match that empty div by id and fill it with the doc content so crawlers + // receive real HTML while JS users still get the SPA hydration on top. + const pageContentRegex = /(]*>)\s*(<\/div>)/ + if (!pageContentRegex.test(html)) { + throw new Error( + `Shell #page-content div not found in dist/index.html. Did website/index.html lose the skeleton structure?` + ) + } + html = html.replace(pageContentRegex, `$1${buildDocContentMarkup(fragment)}$2`) const outDir = path.join(DIST, route.path) const outFile = path.join(outDir, 'index.html') diff --git a/website/index.html b/website/index.html index 26f76d51..0ad79a3e 100644 --- a/website/index.html +++ b/website/index.html @@ -67,7 +67,20 @@ -
+ +
+
+ +
+ +
+
diff --git a/website/src/main.js b/website/src/main.js index 0ebbea87..bfa73c12 100644 --- a/website/src/main.js +++ b/website/src/main.js @@ -97,6 +97,39 @@ function triggerSearchIndexBuild() { }) } +/** + * Hydrate the #app element on boot. + * + * The production build ships website/index.html with a static skeleton + * inside #app: a #shell-header placeholder, a real #page-content div, and + * a #shell-footer placeholder. Reserving those boxes at first paint + * eliminates the empty-→-populated layout shift that used to dominate CLS + * on the homepage (see #432). + * + * If the skeleton is present, replace just the header + footer placeholders + * in place — #page-content remains untouched so the route handlers can + * populate it normally. + * + * If the skeleton is absent (dev server / non-prerendered load) we still + * need the shell, so fall back to the previous full rewrite of #app. + */ +function hydrateShell(app) { + const shellHeader = document.getElementById('shell-header') + const shellFooter = document.getElementById('shell-footer') + if (shellHeader && shellFooter) { + shellHeader.outerHTML = renderHeader() + shellFooter.outerHTML = renderFooter(APP_VERSION) + return + } + const shellHtml = ` + ${renderHeader()} +
+ ${renderFooter(APP_VERSION)} + ` + // eslint-disable-next-line no-unsanitized/property -- trusted template strings + app.innerHTML = shellHtml +} + function initApp() { i18n.init() initTheme() @@ -117,11 +150,7 @@ function initApp() { const app = document.querySelector('#app') if (!app) return - app.innerHTML = ` - ${renderHeader()} -
- ${renderFooter(APP_VERSION)} - ` + hydrateShell(app) applyTranslations() updateThemeIcon() From 2770703a0cf4ae27795c9f3a3b567a1041cb353e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=7BAI=7Df=20D=2E=20M=C3=BCller?= Date: Tue, 14 Apr 2026 11:00:55 +0200 Subject: [PATCH 2/2] fix: remove stale eslint-disable directive for unconfigured rule 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. --- website/src/main.js | 1 - 1 file changed, 1 deletion(-) diff --git a/website/src/main.js b/website/src/main.js index bfa73c12..6c8058f4 100644 --- a/website/src/main.js +++ b/website/src/main.js @@ -126,7 +126,6 @@ function hydrateShell(app) {
${renderFooter(APP_VERSION)} ` - // eslint-disable-next-line no-unsanitized/property -- trusted template strings app.innerHTML = shellHtml }