Skip to content

Commit 2a5802f

Browse files
authored
Fix production FOUC caused by manifest key mismatch (#1084)
## Problem Production builds had a Flash of Unstyled Content (FOUC). Our module IDs use a leading slash while Vite's manifest keys do not, so stylesheet and preload lookups silently found nothing. No CSS links were included in the server-rendered HTML. ## Solution We normalize module IDs to match the manifest key format before lookup. We also added a CSS e2e test that verifies the production HTML includes stylesheet links by loading the page with JavaScript disabled. Fixes #1083
1 parent c256be8 commit 2a5802f

File tree

24 files changed

+640
-2
lines changed

24 files changed

+640
-2
lines changed
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Investigate FOUC in Production Starter App
2+
3+
## Task Narrative
4+
5+
A user reported a Flash of Unstyled Content (FOUC) when deploying a fresh `create-rwsdk` project. The CSS loads separately rather than blocking in `<head>` as expected. The issue reproduces both in dev (`pnpm dev`) and in production (`pnpm release` to Cloudflare Workers). The user referenced PR #638 as a possible regression source, but we are told not to be misled by that -- #638 was largely about dev, while this issue manifests in production.
6+
7+
**Repro steps** (from the report):
8+
1. `pnpx create-rwsdk my-project-name`
9+
2. `cd my-project-name && pnpm i`
10+
3. `pnpm dev` (FOUC happens)
11+
4. `pnpm release` (FOUC happens at deployed URL)
12+
13+
The critical concern is production. FOUC in dev is a known, accepted trade-off (documented in `docs/architecture/clientStylesheets.md`).
14+
15+
## Synthesized Context
16+
17+
### How CSS Prevention Works in Production (from `docs/architecture/clientStylesheets.md`)
18+
19+
The system has a two-phase approach:
20+
21+
**Phase 1 - Script Discovery**: Two mechanisms populate `requestInfo.rw.scriptsToBeLoaded`:
22+
- **Static entry points**: `transformJsxScriptTagsPlugin` (`sdk/src/vite/transformJsxScriptTagsPlugin.mts`) parses `<script>` and `<link rel="modulepreload">` tags in Document.tsx at build time. It wraps JSX calls with side effects like `requestInfo.rw.scriptsToBeLoaded.add("/src/client.tsx")`.
23+
- **Dynamic components**: `registerClientReference` (`sdk/src/runtime/register/worker.ts`) intercepts the `$$id` getter on client references. When the RSC stream serializes a "use client" component, accessing `$$id` adds the module ID to `scriptsToBeLoaded`.
24+
25+
**Phase 2 - Stylesheet Injection**: The `Stylesheets` component (`sdk/src/runtime/render/stylesheets.tsx`) iterates `scriptsToBeLoaded`, looks up each module in the Vite manifest to find associated CSS files, and renders `<link rel="stylesheet" href={href} precedence="first" />` tags.
26+
27+
**Phase 3 - React 19 Hoisting**: The `precedence="first"` attribute causes React 19 to hoist `<link>` tags into `<head>` during SSR streaming, even though `Stylesheets` renders inside `<body>` (as a child of Document's `{children}`).
28+
29+
### Rendering Pipeline (from `sdk/src/runtime/render/renderDocumentHtmlStream.tsx`)
30+
31+
1. RSC payload stream is fully consumed via `await createThenableFromReadableStream(rscPayloadStream)` -- this ensures all `registerClientReference` `$$id` getters have fired
32+
2. Document element is created with `<Stylesheets>` and `<Preloads>` as children
33+
3. Document is rendered to HTML stream via `renderHtmlStream`
34+
4. App HTML stream is rendered separately
35+
5. Both streams are stitched together via `stitchDocumentAndAppStreams`
36+
37+
### The Starter Template (from `starter/src/app/`)
38+
39+
- `document.tsx`: Contains `<link rel="modulepreload" href="/src/client.tsx" />` in `<head>` and `<script>import("/src/client.tsx")</script>` in `<body>`
40+
- `pages/welcome.tsx`: A `"use client"` component importing `welcome.module.css` (CSS Modules)
41+
- `pages/Home.tsx`: Server component rendering `<Welcome />`
42+
43+
### Manifest Handling (from `sdk/src/runtime/lib/manifest.ts`, `sdk/src/vite/linkerPlugin.mts`)
44+
45+
- In dev: `getManifest()` returns `{}` (empty) -- no CSS links rendered server-side (known trade-off)
46+
- In production: The string `"__RWSDK_MANIFEST_PLACEHOLDER__"` is replaced by the linker plugin with the actual Vite client manifest JSON
47+
- The linker reads the client manifest from disk and replaces the placeholder in the worker bundle
48+
49+
## Known Unknowns
50+
51+
1. **Manifest content**: What does the production manifest actually contain? Do the keys match what `scriptsToBeLoaded` stores? A key mismatch (e.g. `/src/app/pages/welcome.tsx` vs `src/app/pages/welcome.tsx`) would cause `findCssForModule` to return no CSS.
52+
53+
2. **React 19 `precedence` hoisting in streaming SSR**: Does React's `precedence` attribute actually hoist `<link>` tags to `<head>` during `renderToReadableStream`? Or does it only work during client-side rendering? If it doesn't hoist during SSR, the `<link>` tags would end up in `<body>`, which is non-blocking and causes FOUC.
54+
55+
3. **Stream stitching interaction**: Could `stitchDocumentAndAppStreams` interfere with where the `<link>` tags end up in the final HTML? The stitcher has specific logic for handling `<head>` content.
56+
57+
4. **What the actual HTML response looks like**: We need to inspect the raw HTML from a production deployment to see where (if at all) the `<link rel="stylesheet">` tags appear.
58+
59+
5. **`scriptsToBeLoaded` population timing**: Is `scriptsToBeLoaded` actually populated by the time `Stylesheets` renders? The RSC stream is awaited first, but we should verify the `$$id` getter actually fires during stream consumption.
60+
61+
6. **CSS module specifics**: The starter uses CSS Modules (`welcome.module.css`), not plain CSS. Does the manifest handle these differently?
62+
63+
## Investigation: Deployed HTML and Manifest Key Mismatch
64+
65+
### Deployed fouc-repro to Cloudflare Workers
66+
67+
Created `playground/fouc-repro` by copying `hello-world` and adding a `"use client"` component (`Welcome.tsx`) with CSS Modules import, matching the starter template pattern.
68+
69+
Built and deployed to `https://fouc-repro.redwoodjs.workers.dev/`.
70+
71+
### HTML Response Analysis
72+
73+
Fetched raw HTML via `curl`. Key observations:
74+
75+
1. **No `<link rel="stylesheet">` tag anywhere in the response** -- the `Stylesheets` component rendered nothing
76+
2. CSS module class names are correctly applied in the SSR output (e.g., `class="_container_yaxpn_1"`) -- SSR bridge is working fine for class name resolution
77+
3. The CSS file (`Welcome-DfGrmhxX.css`) exists in the build output but is never referenced in the HTML
78+
4. CSS only loads when client JS executes: browser loads `client-CGCk5-s-.js` -> dynamically imports `Welcome-CskV7DQb.js` -> that imports the CSS -> FOUC
79+
80+
### Root Cause: Leading Slash Mismatch Between `scriptsToBeLoaded` IDs and Manifest Keys
81+
82+
**Evidence:**
83+
84+
The built worker bundle (`dist/worker/index.js`) contains:
85+
- `scriptsToBeLoaded.add("/src/client.tsx")` -- leading slash (from `transformJsxScriptTagsPlugin`)
86+
- `registerClientReference` uses IDs like `"/src/app/pages/Welcome.tsx"` -- leading slash (from `normalizeModulePath`)
87+
88+
The Vite client manifest (`dist/client/.vite/manifest.json`) uses keys like:
89+
- `"src/app/pages/Welcome.tsx"` -- **no leading slash**
90+
- `"src/client.tsx"` -- **no leading slash**
91+
92+
The `findCssForModule` function in `stylesheets.tsx` does a direct lookup: `manifest[scriptId]`. Since `"/src/app/pages/Welcome.tsx" !== "src/app/pages/Welcome.tsx"`, the lookup silently returns no CSS.
93+
94+
**Where the leading slash comes from:**
95+
96+
`normalizeModulePath` (`sdk/src/lib/normalizeModulePath.mts`, line 113) always returns `"/" + cleanRelative` for paths within the project root. This is the Vite-style convention (Vite uses leading-slash paths internally). However, Vite's `manifest.json` uses paths **without** leading slashes.
97+
98+
**Scope of the bug:**
99+
100+
This affects both:
101+
- Static entry points (from `transformJsxScriptTagsPlugin`): `scriptsToBeLoaded.add("/src/client.tsx")`
102+
- Dynamic components (from `registerClientReference`): `scriptsToBeLoaded.add("/src/app/pages/Welcome.tsx")`
103+
104+
Both use `normalizeModulePath` which produces leading-slash IDs. Neither matches the manifest key format.
105+
106+
## Fix Applied
107+
108+
Added a `toManifestKey` helper that strips the leading `/` before looking up module IDs in the Vite manifest. Applied to both:
109+
110+
1. `sdk/src/runtime/render/stylesheets.tsx` -- `findCssForModule` now uses `manifest[toManifestKey(id)]`
111+
2. `sdk/src/runtime/render/preloads.tsx` -- `findScriptForModule` now uses `manifest[toManifestKey(id)]`
112+
113+
The fix is minimal and local to the lookup site, avoiding changes to the broader `normalizeModulePath` contract (which other consumers depend on).
114+
115+
## Verification
116+
117+
After rebuilding the SDK (`cd sdk && pnpm build`) and redeploying the fouc-repro playground, the FOUC is resolved. The `<link rel="stylesheet">` tags now appear in the HTML response, loaded as render-blocking resources in `<head>` via React 19's `precedence="first"` hoisting.
118+
119+
## E2E Test Added
120+
121+
Added `playground/fouc-repro/__tests__/e2e.test.mts` with two tests:
122+
123+
1. `testDevAndDeploy("renders page with styled content")` -- basic smoke test, verifies content renders
124+
2. `testDeploy("production HTML includes stylesheet link to prevent FOUC")` -- the FOUC regression test
125+
126+
The FOUC test disables JavaScript in the Puppeteer page before navigating, so only the server-rendered HTML is present. It then asserts that a `<link rel="stylesheet" href="...css">` tag exists in the HTML. This is deploy-only (`testDeploy`) since dev intentionally has no server-side stylesheet injection (accepted trade-off documented in `docs/architecture/clientStylesheets.md`).
127+
128+
Playground later renamed from `fouc-repro` to `css` to serve as a broader CSS test surface.
129+
130+
## Knowledge Extraction
131+
132+
Promoted to `.docs/learnings/`:
133+
- `vite-manifest-key-format.md` -- Vite manifest keys lack leading slashes, while `normalizeModulePath` produces them
134+
- `e2e-fouc-test-pattern.md` -- Pattern for testing FOUC: disable JS in Puppeteer, assert `<link>` in SSR HTML
135+
136+
## Draft PR
137+
138+
### Problem
139+
140+
Production builds suffered from a Flash of Unstyled Content (FOUC). The `Stylesheets` and `Preloads` components failed to render `<link>` tags in the server-sent HTML, causing CSS to load only after client JavaScript executed.
141+
142+
### Solution
143+
144+
The root cause was a key format mismatch between module IDs in `scriptsToBeLoaded` and Vite's client manifest. Our `normalizeModulePath` returns Vite-style paths with a leading slash (`/src/app/pages/Welcome.tsx`), but Vite's `manifest.json` keys omit the leading slash (`src/app/pages/Welcome.tsx`). The direct `manifest[scriptId]` lookup in both `findCssForModule` and `findScriptForModule` silently missed every entry.
145+
146+
We added a `toManifestKey` helper that strips the leading slash before manifest lookups, in both `stylesheets.tsx` and `preloads.tsx`. We also added a `playground/css` e2e test that verifies the production HTML contains a `<link rel="stylesheet">` tag by navigating with JavaScript disabled.

playground/css/.gitignore

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Node modules
2+
node_modules
3+
4+
# Logs
5+
logs
6+
*.log
7+
npm-debug.log*
8+
pnpm-debug.log*
9+
10+
# Environment variables
11+
.env
12+
.dev.vars
13+
14+
# Vite build output
15+
dist
16+
17+
# TypeScript
18+
*.tsbuildinfo
19+
20+
# IDEs and editors
21+
.vscode/
22+
.idea/
23+
*.suo
24+
*.ntvs*
25+
*.njsproj
26+
*.sln
27+
*.sw?
28+
29+
# MacOS
30+
.DS_Store
31+
32+
# Optional npm cache directory
33+
.npm
34+
35+
# Optional eslint cache
36+
.eslintcache
37+
38+
# Optional stylelint cache
39+
.stylelintcache
40+
41+
# Optional REPL history
42+
.node_repl_history
43+
44+
# Output of 'npm pack'
45+
*.tgz
46+
47+
# pnpm store directory
48+
.pnpm-store
49+
50+
# dotenv environment variables file
51+
.env.local
52+
.env.development.local
53+
.env.test.local
54+
.env.production.local
55+
56+
# Vite cache
57+
.vite
58+
59+
# Coverage directory used by tools like istanbul
60+
coverage
61+
62+
# Temporary files
63+
*.tmp
64+
*.temp
65+
66+
# Wrangler
67+
.wrangler

playground/css/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# RedwoodSDK Minimal Starter
2+
3+
This is the starter project for RedwoodSDK. It's a template designed to get you up and running as quickly as possible.
4+
5+
Create your new project:
6+
7+
```shell
8+
npx create-rwsdk my-project-name
9+
cd my-project-name
10+
npm install
11+
```
12+
13+
## Running the dev server
14+
15+
```shell
16+
npm run dev
17+
```
18+
19+
Point your browser to the URL displayed in the terminal (e.g. `http://localhost:5173/`). You should see the RedwoodSDK welcome page in your browser.
20+
21+
## Further Reading
22+
23+
- [RedwoodSDK Documentation](https://docs.rwsdk.com/)
24+
- [Cloudflare Workers Documentation](https://developers.cloudflare.com/workers)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import {
2+
poll,
3+
setupPlaygroundEnvironment,
4+
testDeploy,
5+
testDevAndDeploy,
6+
} from "rwsdk/e2e";
7+
import { expect } from "vitest";
8+
9+
setupPlaygroundEnvironment(import.meta.url);
10+
11+
testDevAndDeploy("renders page with styled content", async ({ page, url }) => {
12+
await page.goto(url);
13+
14+
const getPageContent = () => page.content();
15+
16+
await poll(async () => {
17+
const content = await getPageContent();
18+
expect(content).toContain("FOUC Repro");
19+
return true;
20+
});
21+
});
22+
23+
testDeploy(
24+
"production HTML includes stylesheet link to prevent FOUC",
25+
async ({ page, url }) => {
26+
// We disable JS so the page renders only the server-sent HTML.
27+
// This lets us assert that the <link rel="stylesheet"> is present in the
28+
// initial SSR response, which is what actually prevents FOUC -- if it only
29+
// appears after JS hydration, the browser paints unstyled content first.
30+
await page.setJavaScriptEnabled(false);
31+
await page.goto(url);
32+
33+
const content = await page.content();
34+
35+
// The server-rendered HTML must contain a stylesheet link pointing to a
36+
// hashed CSS asset. This is the FOUC prevention invariant: the browser
37+
// must discover the CSS before first paint, without relying on client JS.
38+
expect(content).toMatch(/<link[^>]+rel="stylesheet"[^>]+href="[^"]*\.css"/);
39+
},
40+
);

playground/css/package.json

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"name": "css",
3+
"version": "1.0.0",
4+
"description": "A bare-bones RedwoodSDK starter",
5+
"main": "index.js",
6+
"type": "module",
7+
"keywords": [],
8+
"author": "",
9+
"license": "MIT",
10+
"private": true,
11+
"scripts": {
12+
"build": "vite build",
13+
"dev": "vite dev",
14+
"dev:init": "rw-scripts dev-init",
15+
"preview": "vite preview",
16+
"worker:run": "rw-scripts worker-run",
17+
"clean": "npm run clean:vite",
18+
"clean:vite": "rm -rf ./node_modules/.vite",
19+
"release": "rw-scripts ensure-deploy-env && npm run clean && npm run build && wrangler deploy",
20+
"generate": "rw-scripts ensure-env && wrangler types --include-runtime false",
21+
"check": "npm run generate && npm run types",
22+
"types": "tsc"
23+
},
24+
"dependencies": {
25+
"react": "19.2.4",
26+
"react-dom": "19.2.4",
27+
"react-server-dom-webpack": "19.2.4",
28+
"rwsdk": "workspace:*"
29+
},
30+
"devDependencies": {
31+
"@cloudflare/vite-plugin": "1.26.1",
32+
"@cloudflare/workers-types": "4.20260307.1",
33+
"@types/node": "~24.12.0",
34+
"@types/react": "19.2.14",
35+
"@types/react-dom": "19.2.3",
36+
"typescript": "5.9.3",
37+
"vite": "7.3.1",
38+
"vitest": "^4.0.18",
39+
"wrangler": "4.71.0"
40+
},
41+
"pnpm": {
42+
}
43+
}
Lines changed: 19 additions & 0 deletions
Loading
Lines changed: 23 additions & 0 deletions
Loading
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export const Document: React.FC<{ children: React.ReactNode }> = ({
2+
children,
3+
}) => (
4+
<html lang="en">
5+
<head>
6+
<meta charSet="utf-8" />
7+
<meta name="viewport" content="width=device-width, initial-scale=1" />
8+
<title>@redwoodjs/starter-minimal</title>
9+
<link rel="modulepreload" href="/src/client.tsx" />
10+
</head>
11+
<body>
12+
{children}
13+
<script>import("/src/client.tsx")</script>
14+
</body>
15+
</html>
16+
);

0 commit comments

Comments
 (0)