fix: CSS modules not working in _app/_layout/_error and across routes in non-island components#3781
Conversation
fa5fa74 to
f1934f0
Compare
bartlomieju
left a comment
There was a problem hiding this comment.
Nice fix — the approach of threading css through the existing command → segment → context pipeline is clean and consistent with the rest of the architecture. A few things caught my eye below (inline), but overall this looks solid.
The setAdditionalStyles refactor from a simple setter to a dedup-accumulator is exactly right, getSsrModule() properly handles both real files and virtual module IDs, and the BFS graph walk in collectRouteCss is thorough.
Let me know if you'd like a hand getting this over the finish line — happy to help with any of the items below or with testing.
| `["__FRESH_CSS_PLACEHOLDER__"]`, | ||
| info.css | ||
| ? JSON.stringify(info.css.map((css) => `/${css}`)) | ||
| : "null", |
There was a problem hiding this comment.
This uses content.replace() which only replaces the first occurrence of the placeholder. If two utility-file CSS placeholders (e.g. from both _app and _layout) get hoisted into the same shared chunk like server-entry, only the first one would be replaced.
Should this be replaceAll (or a loop) to be safe?
Alternatively, if each route virtual module gets its own unique placeholder, this is fine — but it might be worth a comment explaining why a single replace is sufficient.
There was a problem hiding this comment.
Nice catch. I notice that it causes requesting to http://localhost:8000/__FRESH_CSS_PLACEHOLDER__ in build mode. It should be replaceAll. Fixed and added a test.
|
|
||
| route.css = server === undefined | ||
| ? route.css | ||
| : await collectRouteCss(server, route.filePath); |
There was a problem hiding this comment.
This re-walks the full SSR import graph and overwrites route.css on every load call (including HMR invalidations). That seems intentional for picking up changes during dev, but for deep dependency trees the fetchModule calls inside collectRouteCss could add up.
Might be worth a short comment here noting this is intentional for HMR correctness, so a future reader doesn't try to "optimize" it with caching.
There was a problem hiding this comment.
Sorry for confusing, it seems to be my AI slop code. This code is unnecessary for HMR.
I'm not sure if the HMR is working with the changes in this PR. Since it definitely works after a page reload, I'm thinking of putting it out of scope for now.
| @@ -298,16 +321,18 @@ export class Context<State> { | |||
|
|
|||
There was a problem hiding this comment.
Nit: appDef?.css evaluates to undefined when appDef is null, which the new setAdditionalStyles handles via the css == null guard. Works correctly, but it's a subtle null-propagation chain. An explicit guard might be clearer:
if (appDef !== null) {
setAdditionalStyles(this, appDef.css);
}Especially since the appDef !== null checks already exist a few lines below.
|
|
||
| if (ctx.#additionalStyles === null) { | ||
| ctx.#additionalStyles = css.slice(); | ||
| return; |
There was a problem hiding this comment.
Minor: includes() is O(n) per insertion. For the small CSS lists in practice this is fine, but a Set would be more idiomatic if you ever want to tighten this up. Low priority.
There was a problem hiding this comment.
Yep, I added a FIXME comment below. I think it could be use Set instead of css: Array<string>.It seems that at least all the tests pass in the following branch.
Hajime-san/fresh@fix-ssr-css-modules...Hajime-san:fresh:perf-css
I'm planning it as a follow up PR.
| }); | ||
| }, | ||
| ); | ||
| }, |
There was a problem hiding this comment.
The tests cover _layout.tsx which is great. The PR title also mentions _app, _error, and _404 — are those paths already exercised by existing tests, or would it be worth adding a case or two here to lock in coverage for those as well?
|
@bartlomieju UPDATE:
It seems an another problem of |
This reverts commit f1934f0.
b069071 to
0395403
Compare
0395403 to
ba75ad3
Compare
1d16070 to
9c1e62f
Compare
| `["__FRESH_CSS_PLACEHOLDER__"]`, | ||
| info.css | ||
| ? JSON.stringify(info.css.map((css) => `/${css}`)) | ||
| : "null", |
There was a problem hiding this comment.
Nice catch. I notice that it causes requesting to http://localhost:8000/__FRESH_CSS_PLACEHOLDER__ in build mode. It should be replaceAll. Fixed and added a test.
| files.push({ | ||
| id, | ||
| filePath: entry.path, | ||
| filePath: toPosix(entry.path), |
There was a problem hiding this comment.
Suddenly, several tests failed on Windows. It's likely that a potential bug was occured by this PR.
vite dev - css modules => ./packages/fresh/src/test_utils.ts:198:8
vite dev - css modules in _app/_layout/_error non-island component are injected => ./packages/fresh/src/test_utils.ts:198:8
vite dev - route css import => ./packages/fresh/src/test_utils.ts:198:8debug console on Windows
------- post-test output -------
👺 > fresh:route-css > name _index
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div class="green">
<h1 class="_root_1mdiz_1">
green text
</h1>
</div>
<div class="red">
<h1 class="_root_xlv1v_1">
red text
</h1>
</div>
<h1>
ok
</h1>
</body>
</html>
[
"Port 5173 is in use, trying another one...",
"",
" VITE v7.3.1 ready in 1276 ms",
"",
" ➜ Local: http://127.0.0.1:5174/"
]
[
"Port 5173 is in use, trying another one...",
"",
" VITE v7.3.1 ready in 1276 ms",
"",
" ➜ Local: http://127.0.0.1:5174/",
"\x1b[2m \x1b[32m➜\x1b[39m \x1b[1mNetwork\x1b[22m\x1b[2m: use \x1b[22m\x1b[1m--host\x1b[22m\x1b[2m to expose\x1b[22m",
"👺 > fresh:route-css > route {",
' id: "/_app",',
' filePath: "D:\\\\a\\\\fresh\\\\fresh\\\\packages\\\\plugin-vite\\\\tests\\\\fixtures\\\\non_island_css_modules\\\\routes\\\\_app.tsx",',
' type: "app",',
' pattern: "*",',
' routePattern: "*",',
" lazy: false,",
" css: [",
' "/@fs/D:/a/fresh/fresh/packages/plugin-vite/demo/components/CssModulesNonIsland.module.css"',
" ],",
" overrideConfig: undefined",
"}",
"👺 > fresh:route-css > name __app",
"👺 > fresh:route-css > route {",
' id: "/_layout",',
' filePath: "D:\\\\a\\\\fresh\\\\fresh\\\\packages\\\\plugin-vite\\\\tests\\\\fixtures\\\\non_island_css_modules\\\\routes\\\\_layout.tsx",',
' type: "layout",',
' pattern: "/",',
' routePattern: "/",',
" lazy: false,",
" css: [",
' "/@fs/D:/a/fresh/fresh/packages/plugin-vite/demo/components/CssModulesNonIsland3.module.css"',
" ],",
" overrideConfig: undefined",
"}",
"👺 > fresh:route-css > name __layout",
"👺 > fresh:route-css > route {",
' id: "/_error",',
' filePath: "D:\\\\a\\\\fresh\\\\fresh\\\\packages\\\\plugin-vite\\\\tests\\\\fixtures\\\\non_island_css_modules\\\\routes\\\\_error.tsx",',
' type: "error",',
' pattern: "/",',
' routePattern: "/",',
" lazy: false,",
" css: [",
' "/@fs/D:/a/fresh/fresh/packages/plugin-vite/demo/components/CssModulesNonIsland2.module.css"',
" ],",
" overrideConfig: undefined",
"}",
"👺 > fresh:route-css > name __error",
"👺 > fresh:route-css > route {",
' id: "/index",',
' filePath: "D:\\\\a\\\\fresh\\\\fresh\\\\packages\\\\plugin-vite\\\\tests\\\\fixtures\\\\non_island_css_modules\\\\routes\\\\index.tsx",',
' type: "route",',
' pattern: "/",',
' routePattern: "/",',
" lazy: true,",
" css: [],",
' overrideConfig: { methods: "ALL" }',
"}",
"👺 > fresh:route-css > name _index",
"👺 > renderRoute > setAdditionalStyles []",
"👺 > render Compose VNode > setAdditionalStyles []",
"👺 > render compose App > setAdditionalStyles []"
]
./packages/plugin-vite/tests/dev_server_test.ts => vite dev - css modules in _app/_layout/_error non-island component are injected ...----- post-test output end -----
./packages/plugin-vite/tests/dev_server_test.ts => vite dev - css modules in _app/_layout/_error non-island component are injected ... FAILED (5s)
./packages/plugin-vite/tests/build_test.ts => vite build - tailwind _app ... ok (6s)
./packages/plugin-vite/tests/build_test.ts => vite build - partial island ... ok (1s)
------- post-test output -------|
|
||
| if (ctx.#additionalStyles === null) { | ||
| ctx.#additionalStyles = css.slice(); | ||
| return; |
There was a problem hiding this comment.
Yep, I added a FIXME comment below. I think it could be use Set instead of css: Array<string>.It seems that at least all the tests pass in the following branch.
Hajime-san/fresh@fix-ssr-css-modules...Hajime-san:fresh:perf-css
I'm planning it as a follow up PR.
This comment was marked as outdated.
This comment was marked as outdated.
ef270d9 to
814e3b5
Compare
| routeFileToName.set(route.filePath, name); | ||
| routeFileToName.set(toPosix(route.filePath), name); |
There was a problem hiding this comment.
seems to be same as https://github.com/denoland/fresh/pull/3781/changes#r3143377420
| const normalized = path.normalize(item.file); | ||
| const normalized = toPosix(path.normalize(item.file)); |
lunadogbot
left a comment
There was a problem hiding this comment.
Reviewed the diff — all four substantive points from the earlier review landed on 493f81a: replaceAll at server_snapshot.ts:536 with a regression test in build_test.ts:392 covering _app/_layout/_error; explicit appDef !== null guard at context.ts:326; the over-eager HMR re-walk was removed in 948dbb94; the includes() dedup at context.ts:205 carries a FIXME with a Set-based follow-up.
One thing worth keeping in mind: every route's virtual fresh-route-css::* module emits the same ["__FRESH_CSS_PLACEHOLDER__"] literal, so when multiple utility files hoist into a shared chunk, replaceAll writes the same combined info.css into each placeholder. That's correct today since setAdditionalStyles dedups on the render side — but if a future chunk-splitting strategy changes the "every placeholder in a chunk wants the same css list" assumption, this will need a rethink.
CI green on 493f81a.
|
@bartlomieju this is ready to merge |
|
I feel like this placeholder situation should have better test coverage |
bartlomieju
left a comment
There was a problem hiding this comment.
Thanks for the thorough fix — especially the dev + prod integration tests and the unit test for replaceFreshCssPlaceholders. The expanded placeholder replacement (all JS chunks containing FRESH_CSS_PLACEHOLDER rather than just _fresh-route___*) is the right shape for hoisted CSS, and the prod test asserting no leftover placeholders in any .mjs is a solid regression guard. Same for the toPosix normalization on both insert and lookup sides.
A couple of things worth double-checking before merging:
UiTree.appandUiTree.layouts[]changed shape (now{ component, css }wrappers). It's behindgetInternals/the internal symbol so blast radius should be small, but worth a grep across the monorepo to be sure nothing external readsinternals.appas a component.- The semantics of
setAdditionalStyleschanged from "replace" to "accumulate + dedup". Looks correct given the new call sites and per-request#additionalStyles, but worth confirming no remaining caller depended on the old replace behavior. _404: the diff threadscssintonewNotFoundCmd, but I didn't see the notFound pipeline pick it up explicitly. The/non_existentintegration test passes so it presumably flows throughrenderRoute→setAdditionalStyles(ctx, route.css)— just calling it out to confirm.
A few smaller suggestions inline.
Move CssModuleNonIsland.tsx and CssModulesNonIsland.module.css from demo/components into the non_island_css_modules fixture directory, and update the _app.tsx import to match. The fixture is now self-contained.
Originally reported:
Repro:
https://github.com/honmanyau/fresh-2-layout-css-module-issue/blob/24bf9c597a057c60acdb2c2934a65f22b6276ecd/routes/page-b/_layout.tsx
The PR seems to resolve the island components problem, but not non-island.
This PR introduces CSS Modules handling for non-island components rendered through utility files such as
_app,_layout,_error, and_404. The core change is that utility-filemod.cssis now preserved through Fresh’s command and render pipeline, so CSS referenced from app/layout/route/error rendering can be accumulated and emitted in the final response instead of being dropped.On the Vite side, development now also collects CSS exposed throughfresh-route-css::*virtual modules, which is the path used by server-rendered non-island components in utility files. For production builds, CSS placeholder replacement is no longer limited to route chunks, because this CSS can be hoisted into shared chunks such asserver-entry.I added new tests and confirmed to work fine about our private project via
linksin deno config locally.