ReviewOS

stacks/stx

generateDocumentShell: a fragment page's <link>/<style> stay in <body>, so SPA navigation renders it unstyled

#1789
Closed glennmichael123 opened this 10 days ago ·
10 days ago

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:

directnavigated
inline <style> blocks64
CSS rules applied69279
page's own 1622-byte :root{…} blockpresentabsent

(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

  1. Create a view with no <html>/<head>/<body>, with a <style> at the top and a <main> below it.
  2. Link to it from another page, without data-no-router.
  3. 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.

  • #1756 — the same class of bug on the stripDocumentWrapper / composeShellWithPage path for full documents.
  • #1787 — hasDocumentShell has the same unanchored-regex shape as stripDocumentWrapper: a fragment containing the literal text <html anywhere, including inside a comment, is treated as already having a shell, skips the wrap, and would defeat this fix.

Sign in to comment on this issue.