Symptom
A fragment page renders correctly on a direct load and unstyled after an SPA navigation. Reloading the same URL fixes it, which makes it look like a caching or hydration problem rather than a shell problem.
Concretely, navigating from a marketing page to /login: the submit button lost its fill, inputs lost their borders, the or divider rules disappeared and the layout left-aligned. Reloading /login rendered it perfectly.
Root cause
generateDocumentShell builds <head> from config only (headConfig.link, headConfig.meta) and drops the rendered fragment into <body> verbatim:
const bodyParts = [
content, // <- the page's own <link>/<style> are in here
...(options.bodyScripts || []).map(s => s),
options.bodyStack || '',
].filter(Boolean)So a page with no <html>/<head>/<body> of its own — which writes its font <link>s and scoped <style> at the top of the file — gets those tags emitted after </head>, inside <body>, outside the swap container:
420 </head>
421 <body data-stx="…">
424 <link rel="preconnect" …> <- page's own
426 <link rel="stylesheet" href="…css2"> <- page's own
428 <style> :root { --accent: … } </style> <- page's own, 1622 bytes
452 <main class="flex items-center …">
496 </main>Compare a full-document page in the same app, where the links are correctly at head lines 18–21 with </head> at 303.
That renders fine on a direct load, because browsers honour <link> and <style> in body. It breaks SPA navigation, because the router reconciles head only:
// packages/router/src/client.ts doSwap()
var newStyles = doc.querySelectorAll('head style');
…
document.querySelectorAll('head ' + linkSel)On a container-only swap the destination's CSS is therefore never applied. Measured on the page above, direct load vs. navigated-to:
| direct | navigated | |
|---|---|---|
inline <style> blocks | 6 | 4 |
| CSS rules applied | 69 | 279 |
page's own 1622-byte :root{…} block | present | absent |
(The 279 is the origin page's stylesheet persisting — the link reconcile is additive by design — so the destination ends up with the wrong CSS in both directions.)
Reproduce
- Create a view with no
<html>/<head>/<body>, with a<style>at the top and a<main>below it. - Link to it from another page, without
data-no-router. - Load it directly — correct. Click through to it — unstyled.
Structural greps do not catch this; the markup is identical in both cases. It only shows up by comparing computed styles, or visually.
Fix
Hoist the fragment's leading <link> / <meta> / <style> run into <head> before composing, mirroring what stripDocumentWrapper({ preserveHead }) already does for full documents (#1756).
Two details that matter:
- The hoisted tags must sit after
options.styles(Crosswind) so the page still wins the cascade exactly as it did when it rendered inside<body>. - Only the leading run should move. Whitespace, comments and
<script>blocks are stepped over but never moved, so a<style>sitting beside the markup it styles is left where the author put it, and a<style>appearing inside a script string is not touched.
<meta> is included because a fragment declaring stx-layout needs it in <head> for the router's layout-group comparison to read it off the fetched document.
Full documents are unaffected — ensureDocumentShell short-circuits on them via hasDocumentShell.
Related
- #1756 — the same class of bug on the
stripDocumentWrapper/composeShellWithPagepath for full documents. - #1787 —
hasDocumentShellhas the same unanchored-regex shape asstripDocumentWrapper: a fragment containing the literal text<htmlanywhere, including inside a comment, is treated as already having a shell, skips the wrap, and would defeat this fix.