Problem
The SSR modulepreload emitter and the source-serving authorization gate disagree about the .server.* boundary, so the framework preloads URLs it then refuses to serve.
Observed live on example-blog.webjs.dev: the homepage HTML emits
<link rel="modulepreload" href="/modules/posts/utils/slugify.ts">
<link rel="modulepreload" href="/modules/posts/types.ts">
<link rel="modulepreload" href="/modules/auth/types.ts">
all of which return 404. Those three files are server-only: slugify.ts is imported only by .server.ts query/action files, and the two types.ts are imported only via import type from .server.ts files. None is reachable from a browser entry.
Design / approach
Root cause is an inconsistency between two graph walks in packages/server/src/module-graph.js:
reachableFromEntries (the authorization gate) stops traversal at .server.{js,ts,mjs,mts} boundaries (SERVER_FILE_RE), so slugify.ts / types.ts are correctly NOT in the servable set, hence 404.
transitiveDeps (which feeds the preload hints at ssr.js:1163) has no such guard and walks straight through the server file into its server-only deps, emitting preload hints for them. The .server.ts files themselves are filtered from preloads by the server-file-index (byIndex) check, but the plain .ts files imported BY them slip past.
Fix: add the same server-boundary stop to transitiveDeps so the two walks structurally agree (the .server.* file may stay in the result, but its imports are not followed). Both elision callers in component-elision.js already pass serverFiles in their skip set, so they are unaffected; the preload emitter is the only caller walking through. This is a framework consistency bug, not a behavior change to elision.
Acceptance criteria
Problem
The SSR modulepreload emitter and the source-serving authorization gate disagree about the
.server.*boundary, so the framework preloads URLs it then refuses to serve.Observed live on example-blog.webjs.dev: the homepage HTML emits
all of which return 404. Those three files are server-only:
slugify.tsis imported only by.server.tsquery/action files, and the twotypes.tsare imported only viaimport typefrom.server.tsfiles. None is reachable from a browser entry.Design / approach
Root cause is an inconsistency between two graph walks in
packages/server/src/module-graph.js:reachableFromEntries(the authorization gate) stops traversal at.server.{js,ts,mjs,mts}boundaries (SERVER_FILE_RE), soslugify.ts/types.tsare correctly NOT in the servable set, hence 404.transitiveDeps(which feeds the preload hints atssr.js:1163) has no such guard and walks straight through the server file into its server-only deps, emitting preload hints for them. The.server.tsfiles themselves are filtered from preloads by the server-file-index (byIndex) check, but the plain.tsfiles imported BY them slip past.Fix: add the same server-boundary stop to
transitiveDepsso the two walks structurally agree (the.server.*file may stay in the result, but its imports are not followed). Both elision callers incomponent-elision.jsalready passserverFilesin theirskipset, so they are unaffected; the preload emitter is the only caller walking through. This is a framework consistency bug, not a behavior change to elision.Acceptance criteria
transitiveDepsdoes not follow edges out of.server.{js,ts,mjs,mts}files<link rel="modulepreload">is emitted for a file outside the browser-bound servable set (preload set is a subset of the gate set, by construction)transitiveDepsstops at the server boundary (counterfactual: fails if the guard is removed)