Skip to content

dogfood: a page/layout <style> churns the CSSOM on every navigation, flashing preserved layouts #1109

Description

@vivek7405

Problem

Navigating in and out of /docs on webjs.dev intermittently flashes the whole page, including the fixed top navbar. The navbar is rendered by the root layout, outside every swap boundary, and a preserved layout is not supposed to repaint at all. That is the point of having layouts.

Reported reproduction (real browser, no scrolling needed, happens once in a while when repeated):

  1. Load https://webjs.dev
  2. Click through to /docs
  3. Click a sidebar menu item
  4. Click the WebJs logo (top left) to return to the landing page
  5. Go to /docs again
  6. Sometimes the whole page flashes, navbar included

What is actually happening

The root layout is NOT being torn down. Tagging every <style> element by object identity and navigating:

LANDING (initial)
  [L0] head    128B  @layer webjs-host{...}              <- framework, preserved
  [L1] head   6995B  /* Foundation tokens + effects */   <- root layout, preserved
  [L2] body   2076B  /* Editor + code tokens ... */      <- the PAGE's own style

AFTER -> /docs
  [L0] head    128B  ... same node
  [L1] head   6995B  ... same node
  [NEW] body  9923B  /* Content typography for doc pages */   <- swapped in

AFTER -> back to landing
  [L0] head    128B  ... same node
  [L1] head   6995B  ... same node
  [NEW] body  2076B  /* Editor + code tokens ... */            <- swapped back

The design tokens and the framework rules survive with identity intact. What churns is a page/layout-level <style> inside the swap range: the landing page owns one, the docs layout owns another, and they trade places on every crossing. (This is why a naive count looks stable at 3 while the bytes swing 9199 to 17046.)

Inserting or removing a stylesheet mutates the document CSSOM, which forces a style recalculation across the entire document, preserved DOM included. That is the mechanism by which a layout that was never touched can still repaint. The site's tokens are oklch() with color-mix() and the header carries backdrop-filter, all comparatively expensive to re-resolve, which is the same shape as #610 (a one-frame oklch() repaint that ordinary instrumentation did not catch).

This is not specific to webjs.dev. Any page or layout carrying its own <style> has the same behaviour, and AGENTS.md currently invites it: "A page/layout, which never hydrates, may interpolate a css result into <style>." In-repo, website/app/page.ts, website/app/docs/layout.ts, examples/blog/app/layout.ts, and the scaffold's own packages/cli/templates/gallery/app/features/layout.ts all do it, so every generated app inherits the pattern.

What has already been ruled out

Do not re-investigate. Each measured against production:

Hypothesis Result
Router falling back to a full page load No. 0 main-frame navigation requests over 15 navigations; JS context survives every hop
The <header> element being replaced No. Tagged the live node; same object after every navigation
Root layout <style> or design tokens churning No. [L0] and [L1] above keep their identity across every crossing
Stylesheet refetch or <link> replacement No. link[rel=stylesheet] preserved, 0 CSS network requests
Layout shift No. PerformanceObserver({type:'layout-shift'}) reports 0 shifts, total 0.0000
Scroll/content desync No. scrollY and document.scrollHeight change in the same animation frame
Deploy-detection hard reload (#899) No. Separate correct behaviour; fires once per deploy and settles
Backdrop-blur sampling moving content No. Ruled out by the atomic scroll/content result; reporter also rejected it from observation

A 258-frame CDP screencast of six cycles showed an identical header signature in every frame, but it sampled only about 11fps, so it cannot rule out a one-frame flash and is not evidence of absence.

Design / approach

Two layers, and the framework one is the reason this issue exists rather than a one-line app fix.

1. Framework: stop steering authors toward per-route <style>, and say what it costs.

The guidance in AGENTS.md and the skill presents a page/layout <style> as simply fine because pages do not hydrate. That is true about hydration and misleading about navigation: a <style> inside a swapped boundary is added or removed on every crossing, invalidating style for the whole document. Static rules belong in the app's compiled stylesheet, which is loaded once by the root layout and never swapped. The guidance should say so, and the scaffold should stop modelling the opposite.

Prior art: Next.js never puts page or layout CSS in the swapped tree, and explicitly engineers against this exact flash. Two findings from their source, both directly applicable:

Hoisted and deduped, never inline. packages/next/src/client/components/app-router.tsx renders runtime styles as <link rel="stylesheet" href={...} precedence="next" />. The precedence attribute is React's stylesheet-hoisting contract: React lifts these into <head> and dedupes by href, so a stylesheet shared between the page you left and the page you entered is never removed and re-added, it is simply untouched. Only genuinely-new CSS is inserted.

Removal is deliberately delayed to avoid a flash. packages/next/src/client/app-link-gc.ts removes a superseded stylesheet on a timer, with this comment:

// Delay the removal of the stylesheet to avoid FOUC
// caused by `@font-face` rules, as they seem to be
// a couple of ticks delayed between the old and new
// styles being swapped even if the font is cached.
setTimeout(() => { otherLink.remove() }, 5)

That is independent confirmation that swapping stylesheets during a client navigation produces a visible flash. Next hit it and worked around it by letting the old and new overlap rather than swapping synchronously. WebJs currently does the synchronous swap with no overlap. Their case is also milder than ours: they swap <link> elements pointing at cached files, while we insert and remove several KB of inline CSS, forcing the same document-wide recalculation with none of the deduping.

This moves the router change from speculative to "there is an established answer". Three separable pieces, in value order:

  1. Do not put static CSS in a swapped boundary (the app fix below, and the scaffold fix in scaffold: gallery layout ships static CSS in a swapped page/layout style block #1110). Removes the churn entirely for the common case. Next gets this free because CSS imports are extracted to files at build time; WebJs has no build step, so the equivalent is authoring static rules in the compiled stylesheet.
  2. Hoist and dedupe. If a page or layout does emit a <style>, the router should hoist it to <head> and key it so an identical style present in both documents is left alone. The router already has the machinery for this in addNewHeadElements.
  3. Overlap rather than swap. When a style genuinely must be replaced, insert the new before removing the old instead of doing both inside one synchronous swap. This is the piece that addresses the flash specifically, as opposed to the wasted work.

2. App: move the docs prose CSS into the compiled stylesheet.

website/app/docs/layout.ts carries about 9.9KB of entirely static CSS. It belongs in website/public/input.css, which already carries the t-* syntax-highlight colors for the same reason. This is a straight relocation with no behaviour change intended.

Note the CSS itself is justified and should not be converted to utilities: doc pages are authored as bare HTML (<h1>, <p>, <ol>, <code>) across 45 files, and Tailwind cannot target unclassed elements. Scoping descendant selectors under one .prose-docs wrapper is the standard prose pattern, the same approach @tailwindcss/typography takes. The problem is where those rules live, not that they exist.

Implementation notes (for the implementing agent)

Where to edit:

  • website/app/docs/layout.ts, the <style> block in the returned html template (roughly L144 onward, through the mobile drawer media query).
  • website/public/input.css is the Tailwind input compiled to /public/tailwind.css and loaded once by website/app/layout.ts. Rebuild from website/ with npx tailwindcss -i ./public/input.css -o ./public/tailwind.css --minify; webjs.dev.before and webjs.start.before run it automatically.
  • Guidance surfaces: AGENTS.md (the components section sentence permitting a page/layout <style>), .agents/skills/webjs/references/styling.md, and the scaffold copies under packages/cli/templates/.
  • If pursuing the hoist-and-diff or overlap options: packages/core/src/router-client.js, addNewHeadElements / _addNewHead and the swap path around reconcileSiblings.
  • To read the Next.js prior art first-hand: packages/next/src/client/components/app-router.tsx (the precedence link render) and packages/next/src/client/app-link-gc.ts (the delayed removal). A clone exists locally at ../next.js relative to this repo on the maintainer's machine; otherwise read them on GitHub.

Landmines / gotchas:

  • Tailwind's preflight strips list-style from ul / ol, and the .prose-docs rules deliberately restore list-style: disc / decimal. Moving them must keep them AFTER preflight in the cascade or the markers vanish. website/test/ssr/docs-chrome.test.ts has a guard ("restores list markers over the Tailwind preflight") that reads the LAYOUT file and will need repointing.
  • The same test asserts the docs layout declares no @theme and no core color tokens by reading app/docs/layout.ts; moving CSS out does not break that but the marker test shares the file.
  • Do not move the rules into the root layout's inline <style>: that ships docs-only CSS on every marketing page. The compiled stylesheet is cached and shared, which is the point.
  • Invariant 9: no backticks inside an html template body, including in CSS comments. This file broke on exactly that three times during seo: serve the docs at webjs.dev/docs with the marketing site chrome #1098.
  • The flash is INTERMITTENT and was not reproducible headlessly via layout-shift observation, screencast, or DOM identity. Verify in a real browser over many repetitions rather than trusting a green instrumented run. dogfood: mobile navbar flickers on forward nav (backdrop-blur sticky header) #610 is the precedent.

Invariants to respect:

  • AGENTS.md code-workflow rule 1: a client-router or browser-facing change needs a browser or e2e assertion, not only a unit test.
  • website/test/ssr/docs-chrome.test.ts compares the docs header and footer byte-for-byte against a marketing page; the docs must render identically after the move.

Tests + docs surfaces:

  • Repoint the list-marker guard at wherever the rules land.
  • Add an assertion that the document's total <style> byte count does not change across a marketing-to-docs navigation. That is the property this establishes and what would catch a reintroduction.
  • Visual check at desktop and mobile widths, light and dark.

Acceptance criteria

  • Navigating between a marketing page and /docs adds and removes no <style> element; total style bytes are unchanged across the crossing
  • The docs render identically (prose scale, list markers, sidebar, mobile drawer, dark mode)
  • The reported cycle, repeated at least 20 times in a real browser, produces no visible flash
  • The guidance no longer presents a page/layout <style> as cost-free, and names the compiled stylesheet as the home for static rules
  • The scaffold's copies of the guidance match (the scaffold's own offending layout is tracked separately in scaffold: gallery layout ships static CSS in a swapped page/layout style block #1110, do not duplicate that work here)
  • The list-marker guard still fires when the list-style restoration is removed (counterfactual)
  • A test pins the no-style-churn property across a navigation

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions