Skip to content

Commit 16eccc4

Browse files
authored
Merge pull request #433 from raifdmueller/fix/cls-prerender-homepage-shell
fix: eliminate CLS 0.975 via static page shell in index.html (#432)
2 parents 4b39519 + 2770703 commit 16eccc4

3 files changed

Lines changed: 68 additions & 21 deletions

File tree

scripts/prerender-routes.js

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -132,18 +132,16 @@ function escapeHtml(str) {
132132
}
133133

134134
/**
135-
* Build the pre-populated markup that goes inside <div id="app">.
136-
* Mirrors the layout produced at runtime by renderHeader() + renderDocPage()
137-
* + renderFooter() in website/src/main.js, but statically — so crawlers see
138-
* real content in the initial HTML response.
135+
* Wrap the doc fragment in the structure that website/src/components/doc-page.js
136+
* produces at runtime: a centered article container with a #doc-content div.
137+
* The content is injected into the shell's #page-content div so crawlers and
138+
* non-JS fetchers see real content in the initial HTML response.
139139
*/
140-
function buildAppMarkup(fragmentHtml) {
140+
function buildDocContentMarkup(fragmentHtml) {
141141
return `
142-
<main class="flex-1">
143-
<article class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
144-
<div id="doc-content" class="asciidoc-content">${fragmentHtml}</div>
145-
</article>
146-
</main>
142+
<article class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
143+
<div id="doc-content" class="asciidoc-content">${fragmentHtml}</div>
144+
</article>
147145
`
148146
}
149147

@@ -187,11 +185,19 @@ function prerenderRoute(shell, route) {
187185
`<link rel="canonical" href="${canonicalUrl}" />`
188186
)
189187

190-
// Inject pre-rendered content into #app
191-
html = html.replace(
192-
/<div\s+id="app"\s*>\s*<\/div>/,
193-
`<div id="app">${buildAppMarkup(fragment)}</div>`
194-
)
188+
// Inject pre-rendered content into the static shell's #page-content div.
189+
// The shell (set up in website/index.html and preserved through vite build)
190+
// contains:
191+
// <div id="page-content" ... style="..."></div>
192+
// We match that empty div by id and fill it with the doc content so crawlers
193+
// receive real HTML while JS users still get the SPA hydration on top.
194+
const pageContentRegex = /(<div\s+id="page-content"[^>]*>)\s*(<\/div>)/
195+
if (!pageContentRegex.test(html)) {
196+
throw new Error(
197+
`Shell #page-content div not found in dist/index.html. Did website/index.html lose the skeleton structure?`
198+
)
199+
}
200+
html = html.replace(pageContentRegex, `$1${buildDocContentMarkup(fragment)}$2`)
195201

196202
const outDir = path.join(DIST, route.path)
197203
const outFile = path.join(outDir, 'index.html')

website/index.html

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,20 @@
6767
</script>
6868
</head>
6969
<body>
70-
<div id="app"></div>
70+
<!--
71+
Skeleton shell: reserves viewport-height layout space so first paint
72+
already has the expected header/content/footer footprint. Without this
73+
the SPA boot caused CLS ~0.975 (empty #app → populated #app). main.js
74+
detects the skeleton via #shell-header / #shell-footer and replaces the
75+
header + footer in place, leaving #page-content untouched. See #432.
76+
-->
77+
<div id="app">
78+
<div id="shell-root" class="flex flex-col" style="min-height: 100vh;">
79+
<div id="shell-header" class="border-b border-[var(--color-border)]" style="min-height: 10.5rem;" aria-hidden="true"></div>
80+
<div id="page-content" class="flex-1" style="min-height: calc(100vh - 16.5rem);"></div>
81+
<div id="shell-footer" class="border-t border-[var(--color-border)]" style="min-height: 6rem;" aria-hidden="true"></div>
82+
</div>
83+
</div>
7184
<script type="module" src="/src/main.js"></script>
7285
</body>
7386
</html>

website/src/main.js

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,38 @@ function triggerSearchIndexBuild() {
9797
})
9898
}
9999

100+
/**
101+
* Hydrate the #app element on boot.
102+
*
103+
* The production build ships website/index.html with a static skeleton
104+
* inside #app: a #shell-header placeholder, a real #page-content div, and
105+
* a #shell-footer placeholder. Reserving those boxes at first paint
106+
* eliminates the empty-→-populated layout shift that used to dominate CLS
107+
* on the homepage (see #432).
108+
*
109+
* If the skeleton is present, replace just the header + footer placeholders
110+
* in place — #page-content remains untouched so the route handlers can
111+
* populate it normally.
112+
*
113+
* If the skeleton is absent (dev server / non-prerendered load) we still
114+
* need the shell, so fall back to the previous full rewrite of #app.
115+
*/
116+
function hydrateShell(app) {
117+
const shellHeader = document.getElementById('shell-header')
118+
const shellFooter = document.getElementById('shell-footer')
119+
if (shellHeader && shellFooter) {
120+
shellHeader.outerHTML = renderHeader()
121+
shellFooter.outerHTML = renderFooter(APP_VERSION)
122+
return
123+
}
124+
const shellHtml = `
125+
${renderHeader()}
126+
<div id="page-content"></div>
127+
${renderFooter(APP_VERSION)}
128+
`
129+
app.innerHTML = shellHtml
130+
}
131+
100132
function initApp() {
101133
i18n.init()
102134
initTheme()
@@ -117,11 +149,7 @@ function initApp() {
117149
const app = document.querySelector('#app')
118150
if (!app) return
119151

120-
app.innerHTML = `
121-
${renderHeader()}
122-
<div id="page-content"></div>
123-
${renderFooter(APP_VERSION)}
124-
`
152+
hydrateShell(app)
125153

126154
applyTranslations()
127155
updateThemeIcon()

0 commit comments

Comments
 (0)