Tobias Koppers
90d · built 2026-09-08
Performance
What Tobias Koppers shipped in the selected window, measured in ETV, and how it compares with the 90 days before it.
Effective capacity
−0.1engineers
delivers like 0.9 (0.9x pre-AI)
Output (ETV)
12.0ETV
−64.2% vs 33.6 prior
Features share
36.3%
+2.9 pp vs prior window
Fixes share
27.3%
+12.7 pp vs prior window
Work mix
36.3% Features4.7% Maintenance26.3% Tests5.4% Docs27.3% Fixes
39 commits over 90 days, ending 2026-09-08.
Where this dev ranks
Percentile against the global top-100 leaderboard (all-time totals).
- By commits
- Top 43 %
- By Features share
- Top 51 %
Daily performance
Daily ETV, stacked by Features, Maintenance, Tests, Docs and Fixes.
Repository spread
Where this developer's commits land. Concentrated work (top1 > 80%) vs polymath spread (top1 < 30%).
Most impactful commits
Top 10 by ETV in the last 90 days.
- 2.3ETVturbo-tasks: execute scheduled tasks inline when they are read (#96808) ### What? Two changes to how the async turbo-tasks engine handles a read whose value isn't ready yet: 1. When a read finds its task merely *scheduled* (queued, not started), it takes the task out of the scheduler queue and executes it on the reading thread instead of waiting for a worker to pick it up. If the execution completes without yielding, the read returns synchronously; if it yields, the partially-polled execution is handed to tokio and the read waits as before. 2. When a read finds its task's execution already **in progress** (a worker is running it), it now waits directly on the task's completion event instead of also attempting to take it over — avoiding a futile acquisition of the scheduler's queue lock, which is otherwise the most contended lock in the system. An inline execution is visible in traces: the executed task's span carries `inline_execution = "complete"` when the reader's poll finished it, or `"partial"` when it yielded and was handed to the runtime. A task a worker executed leaves the field unset. Optional diagnostics (`InlineExecutionStats`) count queue pushes, claim attempts and their outcomes, and reads that waited for an already-running task. They are behind the `inline_execution_stats` Cargo feature, off by default, so a normal build has no counters, no atomics and no extra fields. ### Why? A large share of read misses target tasks that have merely been scheduled, not tasks actually being computed elsewhere. Parking on those adds two avoidable thread hops (schedule → worker pickup → wake) for work the reader could simply do itself. Conversely, a read of a task a worker is already executing gains nothing from trying to claim it, so it shouldn't pay for trying. ### How? - `PriorityRunner` gained a keyed claim: queued entries are indexed by a `ScheduleKey` (task or local task) and stored in a slot store, so one entry can be removed by key under the existing queue lock while the priority heap keeps a tombstone that a popping worker skips. Every scheduled execution still runs exactly once. - The backend already distinguished a queued task from one already executing (`InProgressState::Scheduled` vs. `InProgress`) when building a listener for a read, and discarded that distinction. It's now threaded through as `ReadOutcome<T> = Value(T) | Scheduled(EventListener) | InProgress(EventListener)`; only `Scheduled` reads attempt a claim. The state is a hint — a worker pops a task off the queue before it marks it started, so a read can see `Scheduled` for a task that is no longer claimable. Acting on a stale hint costs one failed claim, never correctness. - Inline execution nests (A reads B inline, B reads C inline, ...), so it's capped at 16 levels per thread to bound stack growth; at the cap, reads fall back to waiting for a worker as before. The cap is a thread-local counter because it guards the *thread* stack, not the task stack. - Recording the outcome on the *executed task's* span needs a small handoff: when `poll_once_or_spawn` returns, that task's span has already been exited, so the executor registers the span it instruments the task body with in a slot that only exists while a claimed task is polled inline. - Added `turbo-tasks-backend/tests/inline_read_execution.rs` (8 tests), `inline_execution_span.rs` and `inline_execution_span_worker.rs`, plus unit tests in `priority_runner.rs`/`manager.rs` covering keyed claim/exactly-once, inline completion for global and local tasks, the in-progress/scheduled distinction, nested inline executions, restoring from a persistent cache, and a deep dependency chain (stack-depth guard). ### Benchmarks `bench/nested-deps-app-router-many-pages` (1000 pages, 3020 generated components, 3010 routes), release builds of `@next/swc` for this branch and for its merge base, runs interleaved, first run of each variant discarded as warm-up. Shared 8-vCPU VM. **Cold `next build --turbopack`** — 5 measured runs per variant: | metric | canary (median) | this branch (median) | delta | | --- | ---: | ---: | ---: | | total build | 96.70 s | 89.29 s | **−7.7 %** | | turbopack compile phase | 68 s | 61 s | **−10.3 %** | Raw totals (ms) — canary: 93566, 95354, 96702, 96717, 97545; branch: 89070, 89140, 89287, 89892, 90462. The distributions don't overlap (the branch is faster in all 25 pairwise comparisons, exact two-sided Mann-Whitney p ≈ 0.008), and the 7.4 s median gap is about twice canary's own 4.0 s run-to-run spread. **Incremental build** (second build reusing the persistent cache) — 3 measured runs per variant: 36.14 s vs 37.29 s median, compile phase 10.4 s vs 10.5 s. The 1.2 s gap is *smaller* than canary's own 1.2 s spread, so this is **no measurable difference** — the incremental case is dominated by restore and by static generation in Node workers rather than by the scheduling this PR changes. **Cache-hit reads** (`task_overhead/turbo-cached-*`, criterion): no regression. Measured before the rebase, on the same read-path code — the hit path never enters this code, since the inline attempt only happens on the branch where a read reports a miss. Caveats: one app and one workload shape, on a shared VM, so only the relative comparison on the same machine is meaningful, not the absolute seconds. <!-- NEXT_JS_LLM --> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com> --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>github.com-vercel-next.js · 33b7edfc · 2026-08-24
- 2.2ETVTurbopack: mangle exported names for smaller bundle sizes (#97672) ### What? Adds export-name mangling to Turbopack, behind a new experimental option `experimental.turbopackMangleExportNames` (default `false`). It is independent of minification: `--no-mangling` is a minifier flag and does not affect it. When enabled, each ECMAScript module's *used* export names — including `default` and `__esModule` — are replaced by short keys in the emitted output, both where the module registers its exports and where every consumer reads them. Modules whose export names could be observed by user code keep their original names, decided per module. This is a reland of #89060 (on top of the already-merged #89406), originally written by Matt Mastracci, who is credited as a co-author on the commit. Stacked on top: #97676 flips the default to `true` on canary releases, so Next.js's own CI exercises the feature broadly before it is considered for stable. ### Why? Bundle size. A module's export keys exist only to link modules together: the producing module emits `{ someVeryLongExportName: … }` and every consumer reads `ns["someVeryLongExportName"]`. Both sides are generated by us, so as long as producer and consumer agree — and the name isn't observable from user code — the key can be a single character. Long export names are extremely common in real dependency graphs (icon sets, utility packages, barrel files), and each one is paid for once in the module that defines it and once per importing module. ### How? **Ported, not rebased.** `canary` is ~2500 commits past the original stack's base, and the files it touched were independently rewritten in the meantime (export-analysis refactor #92781, CJS analysis for scope hoisting #95826, the `module_fragments` subsystem #95978). A probe rebase produced 16 conflicting files on the first commit alone, so the original branches were used as a reference implementation — for intent, the identifier alphabet, and test coverage — and the feature was rebuilt on today's infrastructure. #89561 from the original stack (erasing the Next.js wrapper module types) is deliberately **not** part of this change; it turned out to be unnecessary, because those modules already declare whole-module export usage and therefore back off on their own. **The name table** (`references/esm/mangle/table.rs`) hashes each name into a table of all valid JS identifiers of the smallest length that fits the name set — 15 exports get single-character keys — and resolves collisions by open addressing. Hashing rather than assigning `a`, `b`, `c`, … is what keeps names stable: an unrelated edit elsewhere in the module doesn't renumber every other export, and a collision only perturbs its own cluster. Assignment happens in two passes: every name that is *already* a valid identifier at the chosen length keeps itself and reserves its bucket first, and only then is anything hashed — so an export called `a` keeps `a`, and nothing else can be assigned it. Both passes iterate in sorted order, so the mapping depends only on the set of names. A module with exactly **one** mangleable export is special-cased to a fixed key, `f`, rather than a hashed one. `f` is the most common character in JS keywords (`if`, `for`, `function`), and every single-export module in the graph then emits the same `.f` / `.f()` byte sequences, which gzip's back-references pick up across the whole bundle — a bigger win than hashing, at the cost of that one key changing when a second export is added. A fixed list, `RESERVED_KEYS`, is withheld from every table for two different reasons: JS reserved words (`if`, `in`, `do`, `for`, `let`, `new`, `try`, `var`) are legal as quoted property keys but a minifier will not fold `ns["if"]` into the shorter `ns.if`, so handing one out costs bytes instead of saving them; and `__esModule` is withheld because the runtime's `esm()` helper defines that property on every module's exports object regardless of what the module itself exports, so an assigned key landing on it would collide. (`default` needs no such protection — once it is mangled like any other export, nothing else emits a property under that literal name.) **One source of truth for the mapping.** `mangled_export_names(module, chunking_context)` is a turbo-task that both the producing side (`EsmExports::code_generation`) and the consuming side (`ReferencedAssetIdent::Module`, the single place a cross-module export access is materialized) ask for the *target* module's map. Neither side computes a table of its own, so they cannot disagree, and the task derives export usage from the chunking context itself rather than accepting it as an argument, so a caller can't supply usage from the wrong graph. Re-export chains need no special handling, because the consumer side already resolves through re-exports to the module that produces the binding. The mangling decision itself lives on `EsmExports` as a `mangle_export_names: bool` field, rather than a separate trait method every module type has to override. A module that derives its exports from another one (a facade, a locals module, a part, a rename) inherits the flag with the data, which removed seven hand-written delegations and the possibility of a new wrapper type forgetting one. **A mangling decision must not cross module identities.** A few module types hand out *another* module's exports value as their own (the WASM loader module, the module-fragments side-effects wrapper, the client-reference proxy). If that borrowed value carried a real mangling decision, the producing and consuming sides would key their lookups on two different modules and could compute two different keys for the same export — this actually broke every WASM- and `@vercel/og`-based test once the default-on layer exercised it in CI. `EcmascriptExports::borrowed()` is the one place this is handled: it always returns an unmangled view, and every such pass-through site uses it. **Back-off is per module**, built on the export-usage information that landed after the original PR (`BindingUsageInfo` / `ModuleExportUsageInfo`) rather than the original's locals/facade-split heuristic. A module keeps its names when its usage is `All` (a namespace import that couldn't be lowered, a computed property access, an unresolvable `export *`, or a chunk-group entry — which covers client references and the Next.js wrapper modules), when it is read through a namespace value at all, when its exports are dynamic or not statically known ESM, or when names aren't being mangled in this build. `__webpack_exports_info__` gains `canMangle` and `mangledName` per export, which is how a running test can observe the mapping; with the option off it emits exactly what it emitted before. ### Testing - `turbo-tasks-hash`/table unit tests: encode/decode round-trip, degenerate-name rejection, table sizing, the single-export fixed key (including its own reservation), reserved-word withholding (including a reserved bucket-count test that stays in sync with the reserved list), the preserved-name pass running before any hashing, uniqueness under heavy collision, order independence, wrap-around probing, and same-tier stability. - 13 `turbopack-tests` execution fixtures under `tests/execution/turbopack/exports/mangle-*`, several ported from the original PR and from webpack's `test/configCases/mangle`: named imports, re-export chains and default exports (including one literally named `__esModule`), escaping namespaces (`Object.keys`, `delete ns.missing`, `export * as`, CJS interop), destructuring, prototype-shadowing names (`toString`, `$1`, `__1`), a 60-export two-character table, dynamic `import()` with `webpackExports` / `turbopackExports`, a CommonJS consumer of an ESM module, dynamic re-exports, scope hoisting on and off, and a control with the option off. - 3 committed snapshot fixtures under `tests/snapshot/mangle-exports`, so the emitted keys, the back-off, and the fixed single-export key are visible in review. - One fixture under `__skipped__`, which the harness asserts *fails*, recording the namespace-materialization gap below. - Full suite: 546 unit + 272 execution + 125 snapshot tests pass, with no snapshot churn across the several refactors this PR went through in review. - Verified against real builds: targeted app-dir, worker, WASM, and `@vercel/og`-based e2e suites pass with the option forced on (the failures that remain are external-network tests that fail identically with it off), and a small two-page app shrank by 0.76% of total emitted JS / 0.53% gzipped. ### Known limitations, each intentional - **A module read through `import * as ns` is never mangled**, even when every read is statically tracked, because the analysis doesn't yet distinguish a lowered named read from a materialized namespace object. Namespace imports are common, so this leaves real wins on the table; unlocking it is the highest-value follow-up. - **Escaping namespaces back off entirely.** Webpack instead keeps mangling and materializes a namespace object keyed by the original names. That is the `__skipped__` fixture: implementing it turns the suite red until the fixture is moved out. - **CommonJS export mangling is out of scope.** The producing side is reachable, but the consuming accesses live in user source and nothing rewrites them today; it needs its own design pass. Closes PACK-435 <!-- NEXT_JS_LLM --> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com> <!-- fleet ecdfa248-cd54-41ac-b4a2-c9d49e2a67ee --> --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Luke Sandberg <210140+lukesandberg@users.noreply.github.com>github.com-vercel-next.js · a84bc8de · 2026-08-28
- 2.2ETVGuard filesystem reads against unresolved symlinks (#97902) ### What? Adds debug-only OS realpath validation to successful `DiskFileSystem` file and directory reads. When a successfully canonicalized path differs from the supplied path, the read returns a normal task error naming both paths. Fixes pattern/glob traversal and NFT tracing so physical filesystem access uses resolved paths while logical paths remain available for user-visible specifiers and complete symlink-chain recovery. ### Why? Reading through an unresolved symlink parent gives the same filesystem object multiple path identities. That can make Turbo Tasks dependency tracking and invalidation inconsistent and can produce invalid deployment ZIPs when NFT output contains files below unresolved links. The checks return errors rather than asserting because paths can disagree temporarily under eventual consistency. Propagating a task error avoids panicking a worker thread while still exposing invalid callers during development. ### How? The validation lives directly in `DiskFileSystem::read` and `DiskFileSystem::raw_read_dir`. It calls the OS canonicalization API inline instead of the Turbo Tasks realpath task, keeping the diagnostic out of the task dependency graph. The guard runs only after the OS read succeeds, so missing/non-directory probes preserve their existing behavior. `read_matches` resolves each physical directory immediately before enumeration while retaining logical `PatternMatch` paths. `read_glob` and `track_glob` now resolve their initial directory before enumeration. Symlinks discovered later through wildcard segments are also traversed through resolved targets. `ReadGlobResult` deliberately retains logical paths rooted at the supplied base, allowing consumers to call `realpath_with_links` and recover the complete symlink chain. Consumers follow that contract explicitly: - NFT includes expand each logical match with `realpath_with_links`, emit resolved files and every traversed symlink, skip resolved directory targets, and deterministically deduplicate/sort output. - `import.meta.glob` uses recursive logical keys as the source of user-visible requests, while module resolution follows and tracks symlinks. - the hash-glob example resolves returned logical paths before reading. Webpack-loader context dependencies are covered for both `path/to/symlink/inner/path/*` and `path/to/*/inner/path/*`. The loader fixture performs its directory read with Node `fs`, reports the directory using `addContextDependency`, and Turbopack tracks the resolved target. ### Verification - `cargo fmt -p turbo-tasks-fs -p turbopack-ecmascript -p next-api -- --check` - `cargo clippy -p turbo-tasks-fs -p turbopack-ecmascript -p next-api --all-targets` - `cargo test -p turbo-tasks-fs` (128 passed) - `cargo test -p next-api` (7 passed) - `cargo check -p turbo-tasks-fs --examples` - `import.meta.glob` symlink execution fixture (1 passed) - Nine targeted node-file-trace CI cases with `release-with-assertions` (9 passed) - `pnpm build-all` - `webpack-loader-fs` Turbopack dev e2e (1 passed) - `build-trace-extra-entries-turbo` Turbopack production e2e (1 passed) - twoslash Turbopack production, normal mode (4 passed) - twoslash Turbopack production, cache-components mode (4 passed) - `bench/heavy-npm-deps` Turbopack development smoke test (HTTP 200) ### Notes The disk guard is cross-platform, while its symlink-parent regression test is Unix-only, matching neighbouring symlink tests. On Windows, OS canonicalization can also normalize casing and 8.3 short names; a debug read using a non-canonical spelling will therefore return the same diagnostic error. <!-- NEXT_JS_LLM --> <!-- fleet 1d32e12c-f4ec-4f22-862a-c85f0005805c --> --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>github.com-vercel-next.js · 17a901a7 · 2026-08-28
- 0.5ETVTurbopack: order CSS modules by chunk-group co-occurrence in linearize (#95579) ## Summary The graph-based CSS chunker's `linearize` step produces the global module order that `split_into_chunks` then cuts into chunks. It previously broke ties between ready modules using a weight-sorted stack. It now prefers the ready module that shares the most chunk groups with the previously placed module, so modules that are loaded together end up adjacent in the global order and split into better-aligned chunks. On a real world example this measurably lowers the modeled chunk-loading cost, with fewer requests and less over-fetched CSS. The shared-group count is read from edge weights (how many chunk groups two modules co-occur in). Since `make_acyclic` deletes edges to break cycles, it now returns the edges it cut so `linearize` can still count those conflicting-order co-occurrences — reconstructing the full co-occurrence without cloning the graph. ## Verification - `cargo test -p turbopack-core style_groups_graph::tests` (all pass) - Validated on a real world example that the resulting global order is deterministic and the modeled chunk-loading cost decreases. <!-- NEXT_JS_LLM_PR -->github.com-vercel-next.js · b78f7d3f · 2026-07-13
- 0.5ETVfix(turbopack): support `type: 'text'` in rules, and error on binding imports of non-ESM modules (#96558) `TurbopackModuleType` has listed `'text'` for a while, but the config schema and `ConfiguredModuleType::parse` both rejected it. It now maps to the existing `TextSourceTransform` (the same thing `import … with { type: 'text' }` uses). `'json'`, which Rust already accepted, is added to the TypeScript type and schema too. `type: 'raw'` has always been documented as "Return raw contents as string", but it mapped to an opaque module with no exports instead — that mismatch is why `import * as ns from './alpha.md'` with `{ '*.md': { type: 'raw' } }` evaluated to `undefined`. `raw` is now an alias of `text`: both run the file through `TextSourceTransform` and export its contents as a string. Neither name is deprecated; `text` remains the descriptive spelling that matches the `import … with { type: 'text' }` import attribute, `raw` keeps working exactly as documented. No configuration value maps to the opaque module type anymore; `type: 'asset'` remains the way to emit a file and get its URL. This is also what makes Vite's `?raw` workable: Turbopack has no built-in `?raw` handling, so a `{ condition: { query: '?raw' }, type: 'text' }` (or `'raw'`) rule is what turns those files into strings. Docs previously implied `?raw`/`?url` worked standalone; that's corrected, and the module type table is completed and describes `raw`/`text` as equivalent. Reading a binding off a module that genuinely can't be placed in an ECMAScript chunk (a stylesheet, a native addon, …) used to evaluate to `undefined` with no diagnostic; it now reports an error naming the module. Side-effect-only imports still work. <!-- NEXT_JS_LLM --> --------- Co-authored-by: vercel-fleet[bot] <308483924+vercel-fleet[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>github.com-vercel-next.js · 3ae062a1 · 2026-08-07
- 0.4ETVfeat(turbo-tasks-fetch): stub HTTP on wasm targets (#97585) ### What? Reports HTTP as unsupported on wasm, in the shape of the `reqwest` API `turbo-tasks-fetch` consumes, so the crate compiles for wasm targets. ### Why? `reqwest` cannot serve wasm here. Its only wasm backend targets `wasm32-unknown-unknown` and is built on the browser `fetch` API via `wasm-bindgen`. Under `wasm32-wasip1-threads` that backend is still selected (it keys off `target_arch = "wasm32"`), and it is both API-incompatible (no `ClientBuilder::connect_timeout`, no `ClientBuilder::timeout`, no `Error::is_connect`) and — fatally — **`!Send`**, which `turbo-tasks` requires of every task future. WASI preview1 has no sockets to build a native client on either. ### How? This is deliberately **not** a client and does not pretend to be one: building a client fails immediately with a clear error, so no request is ever attempted, nothing is retried, and no response is ever produced. The remaining types exist only so the shared fetch code type-checks, and the paths that could never be reached say so rather than returning plausible dummy values. The error names the consequence and the alternatives, rather than surfacing as a generic failure: > HTTP requests are not supported in wasm builds of Next.js: this platform has no HTTP client. > Features that fetch at build time, such as `next/font/google`, cannot be used here — self-host the > assets, or use a platform with native Next.js binaries. ### Follow-up Real support needs the **host** to provide HTTP: for wasm builds of the Next.js bindings that means a napi callback into the JS `fetch`, kept behind the existing `FetchClientConfig` interface so callers are unaffected. That is a separate PR by design — a `ThreadsafeFunction` call from a wasi thread cannot be validated until the wasm bindings can be instantiated, which is blocked on the unreleased emnapi v2. <!-- fleet b6d0486f-97c7-42a7-bdaf-3490774cdec3 --> Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>github.com-vercel-next.js · 130ea322 · 2026-08-26
- 0.3ETVfeat(turbopack): resolve `/`-rooted imports from the project directory (#97799) ### What? A request starting with `/` — `import '/content/where'`, `require('/foo.js')` — now resolves from the **project directory** (the one holding `next.config`), and cannot reach outside it. Previously it resolved from the root of the filesystem while reporting *"server relative imports are not implemented yet"*. This is the base of a stack: the follow-up on top of it moves `import.meta.glob`'s `/`-rooted patterns onto the same root, so a pattern and a plain import agree on what `/` means. ### Why? The feature was half-present and mislabelled. The `Request::ServerRelative` arm rewrote `/foo` to `./foo` and resolved it against the filesystem root, then emitted a "not implemented yet" issue unless an import-map alias had already matched. Users got a diagnostic telling them to give up on something that in fact resolved. Worse, resolving from the *filesystem* root means `/` escapes the project. In a Turbopack execution test — where the filesystem root is the repository root — `require('/package.json')` resolved this repository's own `package.json`. In a monorepo, where Turbopack's root is the workspace root and the app lives in `apps/web`, `/content/x.js` would reach a workspace-level file rather than the app's. The intended semantics are Vite's, and they were confirmed by measurement rather than from memory. In a workspace where `content/*.js` exists *both* at the workspace root and under the Vite root, so the two are distinguishable, Vite 5.4.21: - resolves `/content/…` from the configured `root` — changing `root` changes which file wins; - fails outright for a file that exists only above `root`, rather than walking up; - behaves identically for a plain `import` and for `import.meta.glob`. ### How? `ResolveOptions` gains `server_relative_root`, the directory a `/`-rooted request resolves from, and `ResolveOptionsContext` exposes it so embedders can set it. Next.js sets it to the project directory in the client, server and edge resolve contexts — all of which already had it to hand, so nothing new is threaded through. Three deliberate choices: - **The option is optional and defaults to the previous behaviour.** `turbopack-core` is used well beyond Next.js; an embedder that doesn't set it keeps resolving `/` from the filesystem root. This is also why the change is provably contained — the whole `turbopack-tests` suite passes with no snapshot churn. - **It is named and documented in filesystem terms**, tied to the existing `Request::ServerRelative` vocabulary, rather than introducing a "project root" concept into `turbopack-core`, which must stay free of Next.js concepts. - **There is no fallback.** Resolution happens in the configured root and stops; a request that isn't there is a normal module-not-found. A fallback to the filesystem root would reintroduce exactly the escape this fixes. The "not implemented yet" issue is removed. The equivalent issue for Windows-style absolute requests is untouched, as is the separate tsconfig `extends` handling, which treats a rooted path as absolute per TypeScript's own rule. ### Testing The Turbopack execution harness already has two distinct roots — the repository root as the filesystem root, and each test's own directory — so pointing the new option at the test directory makes "project directory" and "filesystem root" genuinely different, and the distinction testable without a Next.js app. Two fixtures: one where a `/`-rooted import and require resolve from the project directory, and one asserting that `/package.json`, which exists only at the filesystem root, does *not* resolve. The former emits no issues at all, which is what pins the removal of the diagnostic. The end-to-end case uses the existing monorepo fixture, where the project directory really is not the filesystem root. A file of the same name exists in both `apps/web/content` and the workspace root, so the rendered value alone identifies which root was used — a test that merely asserted "it renders" would have passed under the old behaviour too. ### Known gap TypeScript resolves a leading `/` as an absolute path on disk, so a `/`-rooted import in a `.ts` file still fails type checking (`TS2307`) even though it bundles and runs. The e2e page is therefore JavaScript. A tsconfig `paths` mapping works around it for users, but it also makes the request match the import map instead, so the two resolvers overlap; that deserves its own decision and is tracked separately rather than papered over here. <!-- NEXT_JS_LLM --> <!-- fleet 3acc7e63-52a8-4472-89cd-b1bbb8ad59fa --> --------- Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com> Co-authored-by: Niklas Mischkulnig <4586894+mischnic@users.noreply.github.com>github.com-vercel-next.js · 43d81b9d · 2026-08-25
- 0.3ETVFix missing styled-jsx styles in Pages Router SSR on adapter builds (#96632) ### What? Fixes styled-jsx styles being missing from the server-rendered HTML of Pages Router apps built through a build adapter (what a Vercel deployment uses), when the app has its own `styled-jsx` dependency. - `crates/next-api/src/next_server_nft.rs`: extracts `styled_jsx_require_hook_modules()` and adds an accurate comment to the `is_using_adapter` early return (it no longer claims no NFT tracing is needed - see "Why?"). - `crates/next-api/src/project.rs`: `Project::additional_traced_modules` now also returns those modules, alongside the existing `cacheHandler`/ `cacheHandlers` entries. - `packages/next/src/server/require-hook.ts`: extracts `styledJsxRequireHookEntries()` (the JS-side equivalent, used by webpack) and replaces the silent `catch (_) {}` around registering the aliases with a diagnostic warning (still never throws). - `packages/next/src/build/adapter/build-complete.ts`, `packages/next/src/build/collect-build-traces.ts`: use that helper instead of re-deriving the same resolution inline. No behavior change for webpack. - `test/production/adapter-styled-jsx/`: new regression test. ### Why? The Pages Router renderer and user code have to share a single `styled-jsx` module instance: `render.tsx` creates the style registry and hands it to user code through a React context owned by that specific module instance. If user code ends up with a *different* `styled-jsx` instance - which happens as soon as the app depends on a `styled-jsx` version that doesn't dedupe with Next.js' own pinned version - `JSXStyle` silently renders nothing during SSR (`if (!registry) return null`). The `jsx-*` class names are still emitted by the transform, so the only visible symptom is a flash of unstyled content: the CSS only gets inserted client-side after hydration. Turbopack keeps `styled-jsx`/`styled-jsx/style` external in the pages server bundle, so the single-instance guarantee is established at **runtime** by `next/dist/server/require-hook`, which resolves those requests to Next.js' own copy from its own install location via a plain `require.resolve()`. Nothing in any module graph references the files that resolves to (user code only ever references its own copy), so output tracing has to add them explicitly, or the deployment is missing the file, the hook's `require.resolve()` throws, the aliases are (silently, since #89402) never registered, and the two `styled-jsx` instances drift apart. That explicit tracing existed only for the whole-app `next-server.js.nft.json` / `next-minimal-server.js.nft.json` - and those are not generated at all when a build adapter is used, because an adapter assembles its output from each endpoint's own NFT instead (`is_using_adapter` early return in `next_server_nft_assets`). Its comment said adapters "don't need any server NFTs" - true for those two whole-app files, but not for the styled-jsx entries they also carried, which had no other way into an adapter build. Reproduced against a published `next@16.3.0` release: a plain `next build` with an adapter never shipped `node_modules/next/node_modules/styled-jsx/style.js`, nothing deleted or hand-edited. ### How? `Project::additional_traced_modules` is Turbopack's existing mechanism for "trace this into every endpoint even though nothing references it" - it already carries `nextConfig.cacheHandler`/`cacheHandlers` for exactly the same reason (a runtime-only dependency invisible to static analysis). Adding the require hook's styled-jsx modules there gets them into every endpoint's own `*.nft.json` via the existing `trace_endpoint` plumbing, which `build-complete.ts` already loads unconditionally for both bundlers - so no TypeScript change is needed on the Turbopack side at all. `styled_jsx_require_hook_modules()` centralizes the resolution (used by both `additional_traced_modules` and the pre-existing whole-app NFT, which still needs it independently for `output: 'standalone'`). Webpack has no equivalent Rust-side tracing, so `build-complete.ts` keeps tracing these itself via `nodeFileTrace`, now through the shared `styledJsxRequireHookEntries()` TS helper instead of re-deriving the same resolution inline. The new test builds a Pages Router page with `<style jsx>` through a build adapter fixture (same shape as `test/production/adapter-root`), with a `styled-jsx` dependency version that doesn't dedupe with Next.js' pinned version, and asserts the adapter's `onBuildComplete` output declares every file the require hook resolves. Verified end-to-end against a published `next@16.3.0` install (the sandbox can't build the native Turbopack addon): - an existing, unmodified mechanism (`cacheHandler`) was used to confirm the endpoint-NFT -> adapter-assets pipeline this fix relies on actually carries a traced module through to the deployment; - the Rust change's exact output was simulated at the point `build-complete` loads each endpoint's NFT, and the SSR HTML went from 0 `<style id="__jsx-...">` tags (with `jsx-*` classes still present - the reported flash) to the expected styled-jsx CSS being present; - reverting the simulation reproduces the failure again; - webpack output (`next/node_modules/styled-jsx/{index,style}.js`, `node-environment`, `require-hook`) is unaffected. - `cargo check` / `cargo clippy` / `rustfmt --check` pass on the Rust change. - [x] Tests added (`test/production/adapter-styled-jsx/`) - [x] Errors have a helpful link attached — n/a, no user-facing error added (warning only) --------- Co-authored-by: vercel-fleet[bot] <308483924+vercel-fleet[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>github.com-vercel-next.js · 2049354a · 2026-08-07
- 0.3ETVfix(turbopack-node): make process_pool inert on wasm (#97858) > Replaces #97584, which was **not merged**. Reordering this stack briefly left that PR > pointing at a base branch that had come to contain its own head commit, so GitHub closed it > as merged and deleted its branch. Nothing from it reached `canary`. It had been approved; > this PR is the same commit (`fb0b97fd84`), restored, and needs review again. Sorry for the churn. ### What? `turbopack-node`'s `process_pool` feature is inert on wasm, leaving `worker_pool` as the only Node backend there. ### Why? The child-process pool needs `tokio::process` and a TCP listener, neither of which exists on wasi: ``` error[E0432]: unresolved import `tokio::process` # gated #[cfg(not(target_os = "wasi"))] in tokio error[E0599]: no `TcpListener::bind` on wasi --> turbopack/crates/turbopack-node/src/process_pool/mod.rs:315 ``` Turning the feature off from the outside is not possible: `process_pool` is a **default** feature of both `turbopack-node` *and* `next-core`, so `--no-default-features` at the top level does not suppress it. `worker_pool` — Node worker threads over napi — is already a first-class alternative selected by `TurbopackPluginRuntimeStrategy`, so no new mechanism is needed. ### How? Gate the seven `process_pool` sites on `not(target_family = "wasm")`: the module, the sealed backend impl, the constructor, the config enum variant, the default-strategy selection, and the `next-api` import and match arm. Host feature semantics are unchanged. <!-- NEXT_JS_LLM --> <!-- fleet b6d0486f-97c7-42a7-bdaf-3490774cdec3 --> Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>github.com-vercel-next.js · 319cdf2b · 2026-08-25
- 0.2ETVfix(turbopack): make the SWC wasm-plugin backend native-only (#97857) > Replaces #97583, which was **not merged**. Reordering this stack briefly left that PR > pointing at a base branch that had come to contain its own head commit, so GitHub closed it > as merged and deleted its branch. Nothing from it reached `canary`. It had been approved; > this PR is the same commit (`7c99dff628`), restored, and needs review again. Sorry for the churn. ### What? Makes the SWC wasm-plugin backend native-only, and reports an **error** against `next.config` when `experimental.swcPlugins` is configured on a platform that cannot execute them. ### Why? `swc_plugin_backend_wasmtime` cannot be hosted inside wasm: there is no JIT, and `wasi-common` needs native filesystem APIs. SWC's own wasm32 path skips plugin transforms for the same reason, see swc-project/swc#3934. Silently dropping the transform is the worst outcome — the build succeeds and quietly produces different output than the config asked for. A warning is not much better: it still emits a bundle whose code was not transformed as configured, which is subtly wrong rather than obviously broken. So this reports an error instead. ### How? - `swc_plugin_backend_wasmtime` moves to a non-wasm dependency, and the plugin rule's implementation is split into native and wasm arms. - The wasm arm emits `UnsupportedSwcPluginsIssue` — `IssueStage::Unsupported`, `IssueSeverity::Error` — naming the configured plugins and pointing at the resolved `next.config` path (via `NextConfig::config_file_path`, as the babel and sass issues do), so the message points at the file the user has to edit. It fires once at config time and only when plugins are actually configured, so projects that don't use them are unaffected. Native behaviour is unchanged. <!-- fleet b6d0486f-97c7-42a7-bdaf-3490774cdec3 --> Co-authored-by: vercel-fleet-prod[bot] <318278635+vercel-fleet-prod[bot]@users.noreply.github.com> Co-authored-by: Tobias Koppers <1365881+sokra@users.noreply.github.com>github.com-vercel-next.js · a3183c01 · 2026-08-25