Skip to content

Commit cfa4827

Browse files
committed
fix(ssr): hoist <link> from layout body into <head>
Layouts emit <link rel="icon">, <link rel="stylesheet">, etc. in their template body, but the SSR pipeline wraps that body in <div data-layout="..."> before the existing hoister ran — so the hoister (a) never saw <link>, and (b) couldn't reach past the wrapper even for <script>/<style>. Result: favicon links sat inside <body>, where browsers don't reliably honour them, and tab icons never showed up on the website / docs / blog. Extend hoistHeadTags to cover void <link> tags and to peek through a leading <div data-layout="..."> wrapper, preserving the wrapper in the body output. Stylesheets and theme bootstrap scripts now also land in <head>, eliminating a small FOUC.
1 parent e211c02 commit cfa4827

2 files changed

Lines changed: 75 additions & 6 deletions

File tree

packages/server/src/ssr.js

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -231,26 +231,39 @@ async function collectMetadata(route, ctx, dev) {
231231
}
232232

233233
/**
234-
* Extract leading `<script>` and `<style>` tags from the body HTML and
235-
* hoist them into `<head>`. Ensures blocking scripts (e.g. Tailwind
236-
* browser runtime, theme bootstrap) run before any body content renders.
234+
* Extract leading `<script>`, `<style>`, and `<link>` tags from the body
235+
* HTML and hoist them into `<head>`. Ensures blocking scripts (e.g.
236+
* Tailwind runtime, theme bootstrap) run before any body content renders,
237+
* and that `<link rel="icon">` / `<link rel="stylesheet">` land where
238+
* browsers reliably honour them.
237239
*
238240
* @param {string} headHtml
239241
* @param {string} bodyHtml
240242
* @returns {{ head: string, body: string }}
241243
*/
242244
function hoistHeadTags(headHtml, bodyHtml) {
243245
const hoisted = [];
244-
const re = /^\s*(<(?:script|style)[\s>][\s\S]*?<\/(?:script|style)>)/i;
245-
let remaining = bodyHtml;
246+
// <script>…</script> and <style>…</style> are paired; <link …> is void.
247+
const re = /^\s*(<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b[^>]*>)/i;
248+
249+
// Step over an optional leading <div data-layout="…"> wrapper. The SSR
250+
// pipeline wraps every layout's output in one of these so the client
251+
// router can detect same-layout navigations; without this peek-through,
252+
// any head-bound tag emitted at the top of a layout template would never
253+
// be hoisted (it would always sit inside the wrapper).
254+
const wrapRe = /^(\s*<div\s+data-layout="[^"]*">\s*)/;
255+
const wm = wrapRe.exec(bodyHtml);
256+
const prefix = wm ? wm[1] : '';
257+
let remaining = wm ? bodyHtml.slice(wm[0].length) : bodyHtml;
258+
246259
let m;
247260
while ((m = re.exec(remaining)) !== null) {
248261
hoisted.push(m[1]);
249262
remaining = remaining.slice(m[0].length);
250263
}
251264
if (!hoisted.length) return { head: headHtml, body: bodyHtml };
252265
const newHead = headHtml.replace('</head>', hoisted.join('\n') + '\n</head>');
253-
return { head: newHead, body: remaining };
266+
return { head: newHead, body: prefix + remaining };
254267
}
255268

256269
// Internal helper re-exported for unit testing.

test/ssr.test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,62 @@ test('hoistHeadTags: is case-insensitive for script/style tags', () => {
9292
assert.equal(body, '<main>ok</main>');
9393
});
9494

95+
test('hoistHeadTags: lifts leading <link rel="icon"> to head', () => {
96+
// Browsers only honour favicons declared in <head>; layouts that emit
97+
// them in their template body must be hoisted, otherwise the tab icon
98+
// never appears.
99+
const bodyHtml =
100+
'<link rel="icon" href="/public/favicon.svg" type="image/svg+xml">' +
101+
'<link rel="apple-touch-icon" href="/public/favicon.png">' +
102+
'<main>page</main>';
103+
const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
104+
assert.ok(head.includes('<link rel="icon" href="/public/favicon.svg" type="image/svg+xml">'));
105+
assert.ok(head.includes('<link rel="apple-touch-icon" href="/public/favicon.png">'));
106+
assert.equal(body, '<main>page</main>');
107+
});
108+
109+
test('hoistHeadTags: lifts a mixed run of leading link/script/style', () => {
110+
const bodyHtml =
111+
'<link rel="icon" href="/f.svg">' +
112+
'<script>var t = "dark";</script>' +
113+
'<link rel="stylesheet" href="/x.css">' +
114+
'<style>.a{}</style>' +
115+
'<main>rest</main>';
116+
const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
117+
assert.ok(head.includes('<link rel="icon" href="/f.svg">'));
118+
assert.ok(head.includes('<script>var t = "dark";</script>'));
119+
assert.ok(head.includes('<link rel="stylesheet" href="/x.css">'));
120+
assert.ok(head.includes('<style>.a{}</style>'));
121+
assert.equal(body, '<main>rest</main>');
122+
});
123+
124+
test('hoistHeadTags: does NOT lift <link> after normal content', () => {
125+
const bodyHtml = '<main>page</main><link rel="icon" href="/late.svg">';
126+
const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
127+
assert.equal(head, '<head></head>');
128+
assert.equal(body, bodyHtml);
129+
});
130+
131+
test('hoistHeadTags: peeks through leading <div data-layout> wrapper', () => {
132+
// The SSR pipeline wraps layout output in <div data-layout="…">; head
133+
// tags emitted at the top of a layout template still need to land in
134+
// <head>, with the wrapper preserved around the remaining body content.
135+
const bodyHtml =
136+
'<div data-layout="layout">' +
137+
'<link rel="icon" href="/public/favicon.svg" type="image/svg+xml">' +
138+
'<script>var t=1;</script>' +
139+
'<main>page</main>' +
140+
'</div>';
141+
const { head, body } = _hoistHeadTags('<head></head>', bodyHtml);
142+
assert.ok(head.includes('<link rel="icon" href="/public/favicon.svg" type="image/svg+xml">'));
143+
assert.ok(head.includes('<script>var t=1;</script>'));
144+
assert.ok(body.startsWith('<div data-layout="layout">'),
145+
`wrapper preserved at start of body, got: ${body.slice(0, 80)}`);
146+
assert.ok(body.includes('<main>page</main></div>'),
147+
'body keeps page content + closing wrapper');
148+
assert.ok(!body.includes('rel="icon"'), 'icon link removed from body');
149+
});
150+
95151
/* ------------ ssrPage integration: cache-control + data-layout wrapping ------------ */
96152

97153
async function makeRoute({ pageSrc, layoutSrc, metadata = null }) {

0 commit comments

Comments
 (0)