Summary
On Windows, one build silently drops dynamic routes from the production build — one serve then returns a bare 404 Not Found for them. Which routes die depends on which generated artifact first hits a Windows-illegal filename character:
- root-level dynamic routes (and route-group-rooted ones like
(chat)/[serverId], since the group strips out of the URL) die on their preload asset name;
- non-SSR dynamic routes at any depth die on their static HTML artifact (
…/:param.html);
- catch-all routes die the same way via
*.
Routes that survive (nested +ssr dynamics) still write their preload assets into NTFS alternate data streams — invisible to directory listings, silently dropped by zip/upload/copy tooling, and absent on any non-NTFS deploy target.
Linux/macOS are unaffected (:/* are legal there — which is why CI is green), dev mode is unaffected (no asset files written). Reproduced identically on v1.17.10 and v1.18.0; this is long-standing, not a recent regression.
Your own suite catches it on Windows, in two packages: test-app-cases (dynamic-route-at-root-with-assets and catch-all-route-at-root-with-assets prod phases) and test-spa-shell-routing (8 prod "Route stability on page reload" failures — those reload /dashboard/[appId] and /thread/[id], which are exactly the dropped routes; dev phase is 27/27 green). It's been masked because turbo test without --continue cancels pending tasks at the first failure, and test-warm-routes used to fail first on Windows.
Root cause
cleanUrl() in packages/one/src/utils/cleanUrl.ts builds preload/loader asset filenames from the route's path pattern, escaping only _ and /:
function cleanUrl(path: string) {
return removeSearch(path)
.replace(/\/$/, '')
.replaceAll('_', '__')
.replaceAll('/', '_')
}
For dynamic routes without concrete params (every dynamic route that doesn't pre-render via generateStaticParams), the path is the Express-style pattern — /:param — so : flows into generated filesystem paths, and the static-HTML writer has the same issue with …/:param.html. On NTFS : is the alternate-data-stream separator (and * is a wildcard), with two distinct failure modes:
- Colon-led path segment → hard failure → route dropped.
build.ts catches the fs error per page, warns, and continues (builtRoutes never gets the route, so it's missing from routeToBuildInfo/pathToRoute, and one serve 404s). The build still exits 0, so nothing fails loudly:
[build] page /(authed)/dashboard/[appId].tsx (with 1 routes)
⚠ skipping page /dashboard/:appId: ENOENT: no such file or directory, open '…\dist\static\dashboard\:appId.html'
[build] page /(chat)/[serverId]/index.tsx (with 1 routes)
⚠ skipping page /:serverId/: ENOENT: no such file or directory, open '…\dist\client\assets\:serverId_97792940_preload.js'
[build] page /[...path]+ssr.tsx (with 1 routes)
⚠ skipping page /*: ENOENT: no such file or directory, open '…\dist\client\assets\*_94387799_preload.js'
- Colon mid-segment → NTFS alternate data stream → invisible asset. The write "succeeds", but the bytes land in a stream on a zero-byte stub file. Forensics from a real build (
blog/[slug]+ssr.tsx):
PS> Get-Item dist/client/assets/blog_ -Stream *
FileName Stream Length
-------- ------ ------
…\dist\client\assets\blog_ :$DATA 0
…\dist\client\assets\blog_ slug_22480461_preload.js 398
…\dist\client\assets\blog_ slug_22480461_preload_css.js 1712
One subtlety worth knowing: serving that dist from the same Windows machine still returns 200 for the stream-trapped URL (Node reads ADS back through the same colon path), so local smoke tests pass — the loss only shows up in listings, copies, and non-NTFS deploys.
Repro (Windows)
cd tests/test-app-cases
ONE_ROUTER_ROOT=app-cases/dynamic-route-at-root-with-assets/app bun run build:web
# build log: ⚠ skipping page /:path: ENOENT … open '…\assets\:path_…_preload.js'
node -e "console.log(Object.keys(require('./dist/buildInfo.json').routeToBuildInfo))"
# → [ './index.tsx' ] — [path]+ssr.tsx is gone
bun run serve & curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/hello-world
# → 404
Control experiments: renaming the param ([slug]) at root → still dropped; the same file one level down (sub/[path]+ssr.tsx) → registered and serves (its preloads land in ADS as above); tests/test-spa-shell-routing → 4 routes dropped per the log excerpt above, and curl http://localhost:<port>/dashboard/test-app → 404 straight from one serve.
Fix direction
Encode all NTFS-reserved characters (: * ? " < > |, plus leading/trailing dots and spaces) wherever route-derived strings become filesystem names — cleanUrl() for preload/loader assets (write sites: cli/buildPage.ts urlPathToFilePath(...) joins), with the matching decode in getPathFromLoaderPath(), plus the static-HTML artifact path for non-prerendered dynamic routes. Note it's not only the :param/* pattern markers: concrete param values from generateStaticParams() flow through the same sink, so a CMS-supplied slug containing a reserved character trips the identical failure on fully-concrete SSG paths — sanitization should cover the whole generated name. Serve-side matching (PRELOAD_JS_POSTFIX_REGEX etc.) is postfix-based and keeps working. A regression test asserting Windows-legal artifact names for dynamic routes, catch-alls, and hostile param values would lock it in.
Happy to send a PR with the encode/decode pair + test if you'd take it.
Environment
- one 1.17.10 and 1.18.0 (both reproduce), Windows 11, Bun 1.3.13, Node 25
- Filesystem: NTFS (the
:/ADS semantics are the trigger)
Summary
On Windows,
one buildsilently drops dynamic routes from the production build —one servethen returns a bare404 Not Foundfor them. Which routes die depends on which generated artifact first hits a Windows-illegal filename character:(chat)/[serverId], since the group strips out of the URL) die on their preload asset name;…/:param.html);*.Routes that survive (nested
+ssrdynamics) still write their preload assets into NTFS alternate data streams — invisible to directory listings, silently dropped by zip/upload/copy tooling, and absent on any non-NTFS deploy target.Linux/macOS are unaffected (
:/*are legal there — which is why CI is green), dev mode is unaffected (no asset files written). Reproduced identically on v1.17.10 and v1.18.0; this is long-standing, not a recent regression.Your own suite catches it on Windows, in two packages:
test-app-cases(dynamic-route-at-root-with-assetsandcatch-all-route-at-root-with-assetsprod phases) andtest-spa-shell-routing(8 prod "Route stability on page reload" failures — those reload/dashboard/[appId]and/thread/[id], which are exactly the dropped routes; dev phase is 27/27 green). It's been masked becauseturbo testwithout--continuecancels pending tasks at the first failure, andtest-warm-routesused to fail first on Windows.Root cause
cleanUrl()inpackages/one/src/utils/cleanUrl.tsbuilds preload/loader asset filenames from the route's path pattern, escaping only_and/:For dynamic routes without concrete params (every dynamic route that doesn't pre-render via
generateStaticParams), the path is the Express-style pattern —/:param— so:flows into generated filesystem paths, and the static-HTML writer has the same issue with…/:param.html. On NTFS:is the alternate-data-stream separator (and*is a wildcard), with two distinct failure modes:build.tscatches the fs error per page, warns, and continues (builtRoutesnever gets the route, so it's missing fromrouteToBuildInfo/pathToRoute, andone serve404s). The build still exits 0, so nothing fails loudly:blog/[slug]+ssr.tsx):One subtlety worth knowing: serving that dist from the same Windows machine still returns 200 for the stream-trapped URL (Node reads ADS back through the same colon path), so local smoke tests pass — the loss only shows up in listings, copies, and non-NTFS deploys.
Repro (Windows)
Control experiments: renaming the param (
[slug]) at root → still dropped; the same file one level down (sub/[path]+ssr.tsx) → registered and serves (its preloads land in ADS as above);tests/test-spa-shell-routing→ 4 routes dropped per the log excerpt above, andcurl http://localhost:<port>/dashboard/test-app→ 404 straight fromone serve.Fix direction
Encode all NTFS-reserved characters (
: * ? " < > |, plus leading/trailing dots and spaces) wherever route-derived strings become filesystem names —cleanUrl()for preload/loader assets (write sites:cli/buildPage.tsurlPathToFilePath(...)joins), with the matching decode ingetPathFromLoaderPath(), plus the static-HTML artifact path for non-prerendered dynamic routes. Note it's not only the:param/*pattern markers: concrete param values fromgenerateStaticParams()flow through the same sink, so a CMS-supplied slug containing a reserved character trips the identical failure on fully-concrete SSG paths — sanitization should cover the whole generated name. Serve-side matching (PRELOAD_JS_POSTFIX_REGEXetc.) is postfix-based and keeps working. A regression test asserting Windows-legal artifact names for dynamic routes, catch-alls, and hostile param values would lock it in.Happy to send a PR with the encode/decode pair + test if you'd take it.
Environment
:/ADS semantics are the trigger)