## Summary
Client-handled Server Action redirects currently return a `303` without
a `Location` header and may include a streamed RSC response body. Some
HTTP intermediaries reject this response or strip its body because it
looks like an HTTP redirect.
Return `200` for client-handled Server Action redirects while continuing
to use `x-action-redirect` and the RSC response. Progressive-enhancement
form submissions still return a standards-compliant `303` with
`Location`.
Fixes#92882Fixes#74026
## Verification
- `HEADLESS=true pnpm test-dev-turbo
test/e2e/app-dir/actions/app-action.test.ts
test/e2e/app-dir/app-basepath/index.test.ts
test/e2e/app-dir/actions/app-action-progressive-enhancement.test.ts`
- `HEADLESS=true pnpm test-start-turbo
test/e2e/app-dir/actions/app-action.test.ts
test/e2e/app-dir/app-basepath/index.test.ts
test/e2e/app-dir/actions/app-action-progressive-enhancement.test.ts`
- `pnpm --filter=next types`
<!-- NEXT_JS_LLM -->
### What?
Update the interactive apps guide's prefetching section for unified
runtime prefetching.
### Why?
#96106 removed the `allow-runtime` segment config, and the guide's
section still described the old model after the mechanical rename: it
told readers session data (cookies, headers) needs the link opt-in, when
the App Shell already carries session-gated UI. Only URL data needs
`prefetch={true}`.
### How?
Rework the section around the per-link model: the shell carries static,
cached, and session output, `prefetch={true}` resolves URL data ahead of
the click, and the static prefetch attempt (#96095) makes the server
cost an internal optimization. Includes a technical-writing pass on the
section (full-noun subjects, split splices) and aligns the Next steps
table row.
The `instrumentation-client` Polyfills docs recommended a conditional
dynamic `import().then()` to load a polyfill. That import is
fire-and-forget: Next.js loads this file synchronously and does not
await it before hydration, so the polyfill can install after components
have already run (see #83905).
- In "Execution timing" mention that only synchronous top-level code
completes before hydration. Call out that a conditional `import()` here
is not awaited, and cross-reference `supported-browsers` for
load-on-demand strategies and the built-in baseline.
- In **`supported-browsers.mdx`**, "fix" the `Conditionally loading
polyfills` example to `await import()` instead of a floating `.then()`,
otherwise, a reader needs to put their code within the `.then` chain of
the polyfill load.
Closes:
https://linear.app/vercel/issue/DOC-6506/revisit-polyfills-with-instrumentation-client
Failing to publish (especially a partial publish) leaves Next.js in a
corrupted state that needs to be addressed immediately. However, we only
created alerts when deploy tests failed which requires a successful
publish.
Now we send special message to the same channel as broken canary and
deploy tests go.
This won't catch a failing deploy-target job. We'll see if deploy-target
also frequently fails/flakes.
Fixes e.g.
https://github.com/vercel/next.js/actions/runs/30314671467/job/90137708228#step:12:603
The `native-builder` image (`scripts/native-builder.Dockerfile`), used
by the `build-native` job of `build_and_deploy.yml` to cross-compile
`next-swc`, installed Node.js 20 via the NodeSource setup script. That
script downloads and imports a signing key before configuring the apt
repository. When the key download returns an HTTP 403 from CI runners
the script still exits successfully, so `apt-get install nodejs` falls
back to Ubuntu's stock `nodejs` package, which does **not** bundle
`npm`. The failure only surfaced two layers later when the image ran
`npm i -g @napi-rs/cli` (`npm: not found`, exit 127), breaking the
native build.
This change installs Node.js 20 from the official `nodejs.org` static
tarball instead. The tarball bundles `npm` and `corepack`, is
glibc-linked (matching the previous intent), and does not depend on
NodeSource's apt repository or key server, removing the flaky external
dependency. The install step also asserts `npm --version`, so a future
regression fails at the install layer rather than several layers later.
## test plan
- [x] [full build_and_deploy on this
branch](https://github.com/vercel/next.js/actions/runs/30372888336)
## Summary
- Preserve the original static worker request when generating the
Turbopack module-context lookup, instead of passing an already-rewritten
URL object.
- Adds an e2e fixture for bare-module Worker URLs with conditional
wildcard exports.
It looks like this might have regressed in #88602Fixes#94015Closes#94043
## Verification
- `cargo check -p turbopack-ecmascript`
- `HEADLESS=true pnpm test-start-turbo
test/e2e/app-dir/worker-module-url/worker-module-url.test.ts`
- `HEADLESS=true pnpm test-start-webpack
test/e2e/app-dir/worker-module-url/worker-module-url.test.ts`
## Summary
- Replaces the per-request verbose checkbox with a global Request
Insights settings menu.
- Persists `requestInsights.verbose` through the existing DevTools
configuration path.
- Restores the setting across reloads.
- Does not include the unrelated global `pagehide` flush behavior.
## Verification
```bash
pnpm build-all
pnpm --filter=next types
pnpm --filter=next build
pnpm test-dev-turbo test/development/app-dir/request-insights/request-insights.test.ts
pnpm test-dev-webpack test/development/app-dir/request-insights/request-insights.test.ts
```
<!-- NEXT_JS_LLM -->
## Summary
- Keeps Request Insights diagnostic spans inside the local recorder.
- Nested generic Next.js spans retain the local parent hierarchy.
- The user’s active OpenTelemetry context remains active and unchanged.
- Diagnostic wrappers are not delegated to or exported through user
OpenTelemetry.
## Verification
```bash
pnpm build-all
pnpm --filter=next types
pnpm --filter=next build
pnpm test-unit --runTestsByPath packages/next/src/server/lib/trace/local-span-recorder.test.ts packages/next/src/server/lib/trace/tracer.test.ts
```
<!-- NEXT_JS_LLM -->
We were missing some test coverage for image optimization. This PR adds
tests for many missing image formats.
Additionally, this PR also ensures the sharp encoders match that
allowlist from detectContentType() such that future versions of sharp
that enable new encoders by default are not automatically enabled in
next/image.
Practically, there is no change to the end user because
detectContentType() runs before sharp so that is the true allowlist, but
this gives us a little more control to align the sharp allowlist as well
(since sharp can release new features in semver minor).
## Summary
Adds `experimental.devMemoryThresholdRestart`, defaulting to `true`, so
projects can set it to `false` to opt out of the development server
restart that occurs when V8 heap usage exceeds 80%. The option has no
effect on `next start`.
## Verification
- Focused config-loading and memory-threshold unit suites passed
- `pnpm --filter=next build` completed JavaScript compilation, then
failed on pre-existing `sharp` and `@vercel/detect-agent` declaration
errors unrelated to this change
<!-- NEXT_JS_LLM -->
Removes the "allow-runtime" prefetch config, and turns its behavior on
implicitly wherever Partial Prefetching is enabled.
The original motivation for "allow-runtime" was to give apps more
control over server costs triggered by prefetches. Until a route
explicitly opts in, prefetches would only be served from the CDN, not
from the server. The problem, though, was it was very confusing to know
when to add or remove this configuration. The incentive for many apps
was to add it everywhere, with no clear signal for when to remove it.
Our updated thinking is that Partial Prefetching itself already provides
sufficient protection against runaway prefetching costs: per-link
prefetches only happen on Link components that explicitly opt in with
the prefetch prop.
The optimizations landed earlier in this stack also make allow-runtime
less necessary: on pages where all the content is statically renderable,
prefetches are served from the static cache and no runtime request is
ever issued; only a page that accesses non-static data is prefetched at
runtime.
The upshot of this decision is that runtime versus static becomes an
internal optimization; the same content gets prefetched regardless of
whether or how Next.js is able to optimize it.
If we're reasonably confident that a segment can be prefetched
statically without omitting data that would have been included during a
runtime prefetch (e.g. cookies), the client should attempt prefetch the
segment statically instead of going straight to a runtime request.
The decision for whether to do this is based on the
ShouldAttemptStaticPrefetch added in previous steps.
If it turns out the static response is not sufficient, then it will fall
back to a runtime request.
This makes prefetching cheaper for pages that are fully statically
renderable. We can make the optimization better in the future with more
reliable per-segment computation of the ShouldAttemptStaticPrefetch, but
the current approach should at least work for fully static pages, which
is what's most important.
This optimization applies during both the Shell phase and the
Speculative phase of the prefetching algorithm.
This is a refactor of the prefetching algorithm to block the prefetch
task from completing until the response is received.
Currently, a prefetch task is fire-and-forget: once a prefetch's
requests are spawned, the scheduler marks the originating task as
complete regardless of what the server ends up returning. However,
certain planned optimizations require us to verify the response from the
server to determine whether additional work is necessary. For example,
we may attempt to do a static prefetch instead of a runtime prefetch if
we optimistically assume that the static prefetch does not vary on
runtime data. We need to verify that assumption is correct before
marking the task as complete.
Now, whenever a pass over the task spawns a segment request, or
encounters an entry whose response hasn't arrived yet, the task
registers itself on the entry and exits as Blocked instead of Done. When
the entry resolves, the blocked task is pinged and the pass re-runs
against the received data. Only a pass that observes every response it
cares about advances the task to the next phase or completes it.
Rejected entries are handled per segment: a rejection counts as an
observed response, so the pass skips that segment and keeps prefetching
the rest. The task never registers on a rejected entry, since nothing
ever pings one. Retries are governed by the existing per-entry logic.
Also fixes a cache bug this exposed: when a revalidation response is
keyed at a more generic vary path than the stale partial entry that
prompted it (because the segment turned out not to vary on some param),
the stale entry shadowed the result on every lookup — the upgraded data
was unreachable and the revalidation was wasted. With blocking, that
dead end became an infinite loop: each fulfillment re-pinged the blocked
task, which re-read the same stale entry and spawned another
revalidation. Upserting (or re-keying) a segment now deletes settled
entries at more specific vary paths that the incoming entry supersedes.
The router-act test helper's "at least one request initiated" watchdog
is adjusted to account for the new blocking behavior: countable requests
can now be legitimately gated on an in-flight App Shell response (which
the watchdog intentionally doesn't count). On expiry it now proceeds to
response processing if the act scope intercepted a shell request, and
keeps waiting while shell requests are in flight elsewhere on the page,
instead of timing out.
Applies the key accounting from #96229 to the other in-memory LRUs with
byte budgets:
- the incremental memory cache
- the default `use cache` handler
- the dev `staticPathsCache` and `serverComponentsHmrCache`
- the dev source-map URL cache.
Keys were free against each budget in the same way; unlike the
filesystem route cache there are no production leak reports for these,
so this is preventative and kept separate. Count-based caches and the
disk-byte LRU are unchanged.
## Test Plan
Each size callback change adds `cacheKey.length` to whatever it returned
before. The memory cache callback is also restructured from early
returns into if/else chains; each `CachedRouteKind` branch returns the
same value as before, plus the key length.
To see the change do something, run this against the built memory cache:
```js
const { getMemoryCache } = require('next/dist/server/lib/incremental-cache/memory-cache.external')
const cache = getMemoryCache(1024 * 1024) // 1 MB budget
for (let i = 0; i < 20000; i++) {
cache.set('/api/data?' + 'p'.repeat(1000) + i, {
value: { kind: 'FETCH', data: { body: 'ok' } },
lastModified: 1,
})
}
console.log(cache.size, cache.currentSize)
```
Before this change, all 20,000 entries are admitted: the cache counts
260 KB against its 1 MB budget while the keys alone hold ~20 MB, and it
keeps growing as long as new keys arrive. After, it holds 1,020 entries
and the counted size stops at the budget.
For regressions: `test/production/app-dir/revalidate` and
`test/e2e/app-dir/use-cache-default-handler-expire-zero` pass in prod
mode (these exercise the fetch cache and the default `use cache`
handler), plus the `packages/next/src/server/lib` jest suites and `pnpm
--filter=next types`.
Co-authored-by: Marcos Hernanz <[email protected]>
`getItem` stores `null` for paths that don't resolve, but checks the
cached value with `if (lruResult)`, so a cached `null` is never served:
every request for an unknown path — including every dynamic-route
request — re-runs the lookup. It's been this way since the cache was
introduced in #52492.
Return the cached entry whenever the key is present. This can't go
stale: in production the item sets are only populated during
`setupFsCheck`, and the LRU is disabled in dev.
Cached misses are stored as a dedicated sentinel instead of `null`.
Since serving a cached miss has no observable effect, no test can catch
this regressing — that is also why it went unnoticed. With the sentinel
it's a type error instead: reintroducing the truthy check fails `pnpm
types` (verified). Same pattern as `invalidSourceMap` in
`source-maps.ts`.
Stacked on #96229, which bounds what negative entries can retain.
## Test Plan
No direct test: the change is unobservable by design, and the existing
e2e suites assert that it stays that way. The regression guard is the
type system, as above. `test/e2e/app-dir/not-found/basic` passes in prod
mode (14/14), exercising the negative-cache path.
Fixes#94890
Adopted from #96187 and #94699, with both authors credited on the
commit.
The router server keeps an in-memory cache that maps request URL paths
to what they resolve to: a page, a static file, or nothing.
It's bounded by a size budget, but the size of each entry was computed
from the cached value only. The URL string used as the key was free, and
"resolves to nothing" entries counted as size 1. So a long-lived server
receiving many unique URLs — bot crawls, long slugs, `/order/:id`-style
traffic — could accumulate up to ~1M URL strings while the cache
believed it was within budget.
That looks like a slow memory leak that tracks how many distinct URLs
you've served rather than how much traffic you get. #94890 has several
production reports, including two deployments that patched key
accounting in and saw memory go flat.
The idea of the fix is to make the cache's bookkeeping match what an
entry actually keeps alive, so the budget becomes a real memory bound.
That takes three things:
1. Count the key string, not just the value.
2. Count the fixed cost an entry has regardless of string lengths — the
per-entry bookkeeping objects, measured at ~120 bytes. Without this, a
flood of *short* unique URLs stays "cheap" on paper while the
bookkeeping itself grows unbounded.
3. Copy strings before caching them. When V8 takes a substring of a long
string (which is how the pathname is extracted from the request URL), it
doesn't copy the characters — it creates a small object that points into
the original string, and the original can't be garbage-collected while
the substring is alive. So caching a 30-char pathname could actually
hold the entire URL in memory, kilobyte query string and all, and the
bookkeeping only sees the 30 chars. Copying the string before caching
makes it hold only its own characters.
With the bookkeeping honest, the budget is set to 8 MiB, roughly the
real memory the cache could already use for legitimate entries before
this change, so capacity isn't reduced — the previous attempt at this
fix (#94942) stalled on that concern.
## Test Plan
A/B on a production `next start` serving 60k requests with unique long
URLs (the reported traffic shape): canary grows +79 MB and keeps
growing; this branch grows +9 MB and plateaus — the budget working as
intended. The per-entry costs above were measured directly on the cache
with forced GC (they fit `118 + key length` bytes within a few percent,
which is also what predicts the A/B numbers). `lru-cache` unit tests
(26/26) and `pnpm --filter=next types` pass.
---------
Co-authored-by: Marcos Hernanz <[email protected]>
Co-authored-by: dmchoi77 <[email protected]>
If we get metadata events, or if we (on Linux with inotify) get file
access events, these aren't useful, and we filter them, but we were
unintentionally extending the deadline for the batch because of them.
Previously we we had 5 total `FxHashSet`s in `BatchedInvalidations`. Now
we've got one `FxHashMap` and one `FxHashSet`. There was potential
overlap in paths represented in all of these sets.
The old implementation was suboptimal for two reasons:
- We ended up cloning and storing `PathBuf` more times. (Note: there's a
good future opportunity for an arena here too)
- If a path was in both `path` and `path_and_children`, we'd later try
to invalidate paths associated with that twice. This will be a no-op as
we have a lock on the invalidator map and the duplicated path has
already removed from the invalidator map, but it makes us do extra map
lookups that aren't needed.
This still keeps around a separate set for `new_paths` so that we can
more efficiently iterate over them. It's possible it could benefit from
being a flag in the same set, but it's less obvious.
I get this is a bit of a silly change in some ways. It isn't going to
have some massive impact. But using dhat I got these stats on a smallish
sample nextjs project
| metric | A (parse.rs change) | B (baseline) | delta |
| ------------------------ | -------------------- | ------------ |
------------------- |
| total bytes allocated | 438.4 MiB | 442.6 MiB | -4.3 MiB (-0.94%) |
| total allocation count | 1,672,688 | 1,872,347 | -199,659 (-10.66%)
Eliminating ~200k allocations from this small change just felt worth it.
- `detectContentType` test was too aggressive about asserting completion
in 1ms, let's give it several tries.
- We weren't correctly setting up pnpm overrides in tests, which breaks
things whenever canary comes out.
Test-only.
The test assertion was racy: it asserted no with-query-param RSC request
during hash-only navigations, but a late prefetch of the
`#link-to-query-param` link makes exactly that request (failed 3/3 on a
prod CI shard).
Assert the actual invariants instead:
- hash-only navigations issue no navigation RSC requests at all
(prefetches, identified by their headers, are ignored)
- a query change *does* issue one — with `prefetch={false}` on the link
so it can't be served from the prefetch cache.
### What?
Restructure the AI agents guide into a step-by-step progression and
close the coverage gaps against the 16.2 and 16.3 AI blog posts.
### Why?
The guide read as a feature list and was missing agent-facing features
the blog posts announced: the actionable error menus with the **Copy
prompt** button and per-error `/docs/messages` pages, the `.md` /
`llms.txt` docs access path, and the `.next/dev/lock` behavior that
keeps agents from stacking dev servers.
### How?
Four steps now sequence the setup (bundled docs, runtime visibility,
errors, skills), with the new content folded in where it belongs. The
skills intro states the knowledge-vs-workflow split (bundled docs are
the source of truth, skills are for workflows). Cross-links the instant
navigation AI workflow and the building guide's `--debug-prerender`
section, and the terminal example uses the current message wording
rather than the blog's pre-stable snapshot. The `#existing-projects`
anchor and per-skill anchors are unchanged for published inbound links;
instant-navigation's link to runtime visibility is updated to the new
heading.
[`httpxy`](https://github.com/unjs/httpxy) is a faster and actively
maintained variant of `http-proxy`. It also doesn't need any manual
patches to avoid security vulnerabilities in Next.js.
`httpxy` is used by Nuxt and maintained by the Nitro team.
The previous `@mswjs/interceptors` has a bug where the response it
forwarded never set the [`complete`
field](https://nodejs.org/api/http.html#messagecomplete). This wasn't an
issue so far (or not reported) but is an issue for `httpxy` which relies
on an accurate `complete` field.
Bumping `@mswjs/interceptor` now so that we can switch from `http-proxy`
to `httpxy`.
Adds a server-computed signal to static per-segment prefetch responses
that tells the client whether a runtime prefetch request would return
more content than the static response already contains.
The signal is computed by tracking whether the prerender accessed any
data source that hangs during a static prerender but would resolve
during a runtime prerender. For example: cookies, headers, fallback
params, and search params.
The information is encoded two places:
- As a prefetch hint called ShouldAttemptStaticPrefetch. This tells the
client that it's worth attempting to do a static prefetch instead of a
runtime one. It's only an optimization, though: if the static response
ends up being insufficient, the client will follow it up with a runtime
request. It's semantically OK if there are false positives.
- Embedded in the static segment response. This tells the client whether
the static response is missing data that would have been included in a
runtime response. Unlike the prefetch hint, this value must never
falsely claim that no runtime request is needed.
A follow-up will update the client to skip the runtime request using
this information. This commit only encodes the signal into the response.
## Summary
- record deferred Instant Insights work as a separate typed Request
Insights item
- keep validation spans, fetches, timing, and live updates separate from
the foreground request
- label Instant Insights consistently in DevTools and CLI output
- stack this observability change on #95939, which moves validation off
the navigation response path
## Verification
- `pnpm jest --runInBand
packages/next/src/server/lib/trace/request-insights.test.ts
packages/next/src/server/lib/trace/local-span-recorder.test.ts
packages/next/src/next-devtools/dev-overlay/components/request-insights/trace-viewer.test.ts
packages/next/src/next-devtools/dev-overlay/shared.test.ts`
- `NEXT_SKIP_ISOLATE=1 NEXT_TEST_PREFER_OFFLINE=1 pnpm test-dev-turbo
test/development/app-dir/request-insights/request-insights.test.ts`
- `NEXT_SKIP_ISOLATE=1 NEXT_TEST_PREFER_OFFLINE=1 pnpm test-dev-webpack
test/development/app-dir/request-insights/request-insights.test.ts`
- `pnpm --filter=next types`
<!-- NEXT_JS_LLM -->
---------
Co-authored-by: David Ilie <[email protected]>
Used Claude + this tool (https://github.com/vercel/next.js/pull/96158)
to look at Turbopack's runtime. With a large fixture with a lot of
imports, this part was bailing out:
```
{
"kind": "deopt",
"bailoutType": "deopt-eager",
"reason": "wrong map",
"functionName": "getChunkRelativeUrl",
"original": {
"source": "turbopack:///[turbopack]/browser/runtime/base/runtime-base.ts",
"line": 486,
"column": 11
},
"severity": "high"
}
```
After the fix, this went away.
---------
Co-authored-by: Luke Sandberg <[email protected]>
Updates the per-segment, static prefetch response type to include a
bytes offset for the App Shell. This will allow the client to extract a
shell response from a more complete response, like we already do for
page-level prerender responses.
All of the significant changes are confined to the server, during the
step when a full page prerender is used to create per-segment responses
(collect-segment-data). The overall strategy is the same one used at the
page level. The input data is unblocked in distinct stages, separated by
macrotasks. At each stage, we record the number of bytes that have been
emitted to the output stream. Then, before closing the final stream, we
embed the byte offsets into the response.
During this phase, no actual user code runs: all we're doing is decoding
and reencoding an existing Flight response. So the timing logic is more
straightforward. For example, we don't need to worry about dynamic data
leaking into the result.
This commit is purely an in-place refactor. Subsequent steps will update
the client to take advantage of the new offsets.
## Summary
Add Vite-compatible `caseSensitive` support to `import.meta.glob()`.
Matching remains case-sensitive by default, while `caseSensitive: false`
applies case-insensitive matching to directory traversal, filenames,
positive patterns, and negative patterns.
The option is included in matcher equality and non-default virtual
module identity, and is exposed through diagnostics, TypeScript
declarations, and the Turbopack options reference.
## Verification
- `cargo test -p turbo-tasks-fs glob`
- `cargo nextest run -p turbopack-tests -E "test(import_meta_glob) and
not test(import_meta_glob_errors)"`
- `cargo fmt --all -- --check`
- `pnpm build-all` and `pnpm --filter=next types` are blocked by
current-canary `sharp.default` type errors in
`packages/next/src/server/image-optimizer.ts`
<!-- NEXT_JS_LLM -->
### What?
Fire-and-forget `setTimeout` calls in the Edge runtime sandbox (e.g.
from middleware) are tracked forever by Next.js's sandbox
`TimeoutsManager`, even after the timeout has run. In a long-lived
standalone/self-hosted server this makes tracked timeout resources grow
monotonically, producing step-like heap growth and eventual OOM.
### Why?
`TimeoutsManager.add()` pushes each timeout id into a `resources` array.
When a one-shot timeout fires naturally, `webSetTimeoutPolyfill`'s
`finally` clears the underlying Node timer (the workaround for
nodejs/node#53335) but never removes the id from the manager. The id is
dropped only if user code calls `clearTimeout(id)` or the whole module
context is torn down (`clearModuleContext`, introduced in #57235). The
natural-completion path — module context stays alive, timeout finishes
on its own — was missing, so ids accumulate for the lifetime of the
process.
### How?
- `TimeoutsManager.create()` now wraps the callback so the timeout
untracks its own id once it has run. The Node-timer workaround and the
`this`/args binding in `webSetTimeoutPolyfill` are preserved.
- Refactored the base `ResourceManager.remove()` into `untrack()` (stop
tracking) + `destroy()`, so natural completion can release tracking
without a redundant `clearTimeout`.
- Intervals are intentionally unchanged: they fire repeatedly and must
remain tracked until `clearInterval`/`removeAll`.
- Added a unit test covering natural release, no monotonic growth across
repeated rounds, the `clearTimeout`/`remove` path, callback `this`/args
binding, and that intervals stay tracked.
Verified: `pnpm --filter=next types` clean, prettier/eslint clean, and
the new unit test fails on the current behavior (reverting only the
release logic) and passes with the fix.
Fixes#95094
---
**Update:** this branch now lands @berry95's commit from #94794 first
(`Set`-based resource tracking, which also makes `untrack`/`remove`
O(1)), with the fix rebased on top.
---------
Co-authored-by: Berry Kim <[email protected]>
Co-authored-by: Dan Abramov <[email protected]>
In a project whose absolute path contains characters that percent-encode
in URLs — e.g. a space — the dev overlay showed no code frame and raw
`file://` URLs instead of source locations for server frames.
Two encoding mismatches caused this:
- Turbopack's `trace_source` percent-decoded the source map's original
file URL before comparing it against the still-encoded project root URI,
so the containment check failed ("Original file ... outside project")
for any project path with such characters. The comparisons now stay in
the encoded domain and only the outputs are decoded back into paths.
- React synthesizes stack frame `file:` URLs by prepending the scheme to
a filesystem path, so the overlay receives URLs with e.g. raw spaces.
These aren't well-formed, and they don't match the `url.pathToFileURL`
form Node.js keys its source map cache by. The overlay middleware now
re-encodes them through WHATWG URL parsing, which tolerates such input.
## Test Plan
New tests.
Known cases that remain broken are commented out with a TODO.
These could be resolved if https://github.com/react/react/pull/37105 or
equivalent lands.
## Before
<img width="825" height="732" alt="Screenshot 2026-07-25 at 14 39 18"
src="https://github.com/user-attachments/assets/0f119e52-ec86-495a-bf5c-5339faa1d606"
/>
## After
<img width="1126" height="733" alt="Screenshot 2026-07-25 at 14 58 14"
src="https://github.com/user-attachments/assets/a263ee8a-4cfe-4358-9a8e-2e58b4b16aa5"
/>
Co-authored-by: Claude Fable 5 <[email protected]>