Skip to content

dogfood: head-hoist stops at HTML comments, leaving the stylesheet in <body> (FOUC) #406

Description

@vivek7405

Symptom

Visiting a webjs app (e.g. https://webjs.dev) sometimes shows a flash of unstyled HTML before the CSS applies.

Root cause (confirmed against the live site + source)

The served HTML has </head> at line 39 but <link rel="stylesheet" href="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/public/tailwind.css"> at line 100, i.e. in <body>. A stylesheet in <body> is not reliably render-blocking, so with streaming SSR + a ~1.45s CSS download the browser paints the SSR'd HTML before the late stylesheet applies.

Why it's in the body: webjs auto-hoists head-bound <link>/<style>/<script> tags returned in a layout into <head> (the shell-in-non-root-layout rule documents this). But the hoist only takes the contiguous leading run of the body:

// packages/server/src/ssr.js  (both hoistHeadTags and collectHoistedHeadTags)
const re = /^\s*(<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b[^>]*>)/i;
while ((m = re.exec(remaining)) !== null) { hoisted.push(m[1]); remaining = remaining.slice(m[0].length); }

The regex does not match <!-- … -->, so the FIRST HTML comment ends the run. The website's root layout (website/app/layout.ts) returns:

<link rel=icon ...> x3            <- hoisted (leading run)
<!-- Self-hosted fonts ... -->    <- COMMENT: run stops here
<link rel=preload ... font>       <- NOT hoisted
<!-- analytics --> <link preconnect> <script gtag> ...
<link rel=stylesheet tailwind.css> <- NOT hoisted -> stays in <body> -> FOUC

So only the 3 favicons hoist; the stylesheet (after comments) is left in the body. Confirmed: the served <head> contains the favicons but NOT the stylesheet / font preloads / GA.

Fix

Make BOTH hoistHeadTags and collectHoistedHeadTags (packages/server/src/ssr.js) skip HTML comments (and whitespace) between head-bound tags, so a run interspersed with comments still fully hoists:

const re = /^\s*(<!--[\s\S]*?-->|<script[\s>][\s\S]*?<\/script>|<style[\s>][\s\S]*?<\/style>|<link\b[^>]*>)/i;
while ((m = re.exec(remaining)) !== null) {
  const tag = m[1];
  if (!tag.startsWith('<!--')) hoisted.push(tag); // consume comments, don't hoist them, keep scanning
  remaining = remaining.slice(m[0].length);
}

This only EXTENDS the leading-run scan across comments; the first real body element still ends the run, so body content is never hoisted.

Affected

  • webjs.dev (the symptom). After the fix + redeploy the stylesheet hoists into <head>, no FOUC. (The website could also work around it by moving the stylesheet above the comments, but the framework should handle it.)
  • Any app whose root layout interleaves comments with head <link>/<style>/<script> tags. Affects both the non-streaming and streaming SSR paths.

Acceptance criteria

  • A layout body with a comment between two head-bound tags hoists BOTH into <head> (counterfactual: the post-comment tag is currently left in body).
  • Both hoistHeadTags and collectHoistedHeadTags covered; unit + a server-level assertion on the served <head>.
  • Dogfood: website / docs / blog served <head> contains <link rel=stylesheet>.

Secondary (not the FOUC, note for later): the stylesheet <link> has no ?v= content hash, and CSS load was ~1.45s; consider fingerprinting + earlier preload.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions