See PR #89600
In turbopack this._compilation is undefined
Expected behavior
I am trying to port next-yak from webpack to turbopack.
next-yak uses a webpack loader to evaluate .yak.ts files at build time, resolving design tokens (spacings, colors, etc.) into static CSS. Think of it as compile-time macros for CSS-in-JS. Other libraries like vanilla-extract and linaria do similar things.
The problem is that there's no way for a loader to know whether it's being called as part of the same compilation batch as another invocation. This makes it impossible to cache shared work across files within a single pass.
As a more concrete example lets say three components all import from spacings.ts:
button.tsx → imports spacings.ts
card.tsx → imports spacings.ts
accordion.tsx → imports spacings.ts
When spacings.ts changes, turbopack correctly invalidates all three and re-runs the loader for each. But each invocation independently evaluates spacings.ts — three times for the same file, in the same pass.
It's worse when only button.tsx changes. The loader has no way to know why it was called, so it must defensively re-evaluate spacings.ts even though nothing about the tokens changed.
For next-yak this means booting a worker thread and importing TypeScript files on every invocation. In larger projects that's 100ms+ per call, even when re-evaluation isn't necessary.
Proposal
Port this._compilation from webpack to turbopack as a frozen object on the loader context, where the same object reference is shared across all loader invocations within the same file-watcher batch.
This lets loaders use it as a WeakMap key for per-compilation caching:
const compilationCache = new WeakMap()
module.exports = function(source) {
if (!compilationCache.has(this._compilation)) {
compilationCache.set(this._compilation, new Map())
}
const cache = compilationCache.get(this._compilation)
const depPath = './tokens/spacings.ts'
if (!cache.has(depPath)) {
cache.set(depPath, expensiveEvaluation(depPath))
}
const tokens = cache.get(depPath)
this.addDependency(depPath)
return transform(source, tokens)
}
When the next batch comes in, a new object is created and the old one gets GC'd along with its WeakMap entries.
No manual cache invalidation needed
Implementation
The watcher already batches file changes (10ms on Linux, 1ms on macOS/Windows). We add a u64 counter on DiskFileSystemInner that increments on each batch flush.
This counter gets passed through the IPC Evaluate message to the Node.js loader process, where it's compared against the previous value — if different, we create a new this._compilation object.
The important constraint is that this marker must stay outside of WebpackLoaderContext.args.
If it were included in args, it would bust turbo-tasks' memoization cache on every compilation, which would defeat the whole purpose.
The marker only matters when the loader actually runs (cache miss); on a cache hit the loader doesn't execute at all.
The EvaluateContext trait gets an async compilation_marker() method that resolves the filesystem via ResolvedVc::try_downcast_type::<DiskFileSystem> (same pattern as to_sys_path()) and reads the counter
Provide environment information
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:40 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T6000
Available memory (MB): 65536
Available CPU cores: 10
Binaries:
Node: 24.13.0
npm: 11.6.2
Yarn: 1.22.22
pnpm: 10.17.1
Relevant Packages:
next: 16.1.6 // Latest available version is detected (16.1.6).
eslint-config-next: N/A
react: 19.2.3
react-dom: 19.2.3
typescript: 5.9.3
Next.js Config:
output: N/A
Which area(s) are affected? (Select all that apply)
Turbopack
Which stage(s) are affected? (Select all that apply)
next dev (local)
Additional context
Every other major bundler gives plugins some way to know what changed in the current pass:
| Bundler |
How plugins know what changed |
| webpack |
this._compilation object shared across loader calls |
| rspack |
this._compilation object shared across loader calls |
| vite |
handleHotUpdate passes the changed modules to plugins |
| turbopack |
⛔️ not possible |
See PR #89600
In turbopack
this._compilationisundefinedExpected behavior
I am trying to port next-yak from webpack to turbopack.
next-yak uses a webpack loader to evaluate
.yak.tsfiles at build time, resolving design tokens (spacings, colors, etc.) into static CSS. Think of it as compile-time macros for CSS-in-JS. Other libraries like vanilla-extract and linaria do similar things.The problem is that there's no way for a loader to know whether it's being called as part of the same compilation batch as another invocation. This makes it impossible to cache shared work across files within a single pass.
As a more concrete example lets say three components all import from
spacings.ts:When
spacings.tschanges, turbopack correctly invalidates all three and re-runs the loader for each. But each invocation independently evaluatesspacings.ts— three times for the same file, in the same pass.It's worse when only
button.tsxchanges. The loader has no way to know why it was called, so it must defensively re-evaluatespacings.tseven though nothing about the tokens changed.For next-yak this means booting a worker thread and importing TypeScript files on every invocation. In larger projects that's 100ms+ per call, even when re-evaluation isn't necessary.
Proposal
Port
this._compilationfrom webpack to turbopack as a frozen object on the loader context, where the same object reference is shared across all loader invocations within the same file-watcher batch.This lets loaders use it as a WeakMap key for per-compilation caching:
When the next batch comes in, a new object is created and the old one gets GC'd along with its WeakMap entries.
No manual cache invalidation needed
Implementation
The watcher already batches file changes (10ms on Linux, 1ms on macOS/Windows). We add a
u64counter onDiskFileSystemInnerthat increments on each batch flush.This counter gets passed through the IPC
Evaluatemessage to the Node.js loader process, where it's compared against the previous value — if different, we create a newthis._compilationobject.The important constraint is that this marker must stay outside of
WebpackLoaderContext.args.If it were included in args, it would bust turbo-tasks' memoization cache on every compilation, which would defeat the whole purpose.
The marker only matters when the loader actually runs (cache miss); on a cache hit the loader doesn't execute at all.
The
EvaluateContexttrait gets an asynccompilation_marker()method that resolves the filesystem viaResolvedVc::try_downcast_type::<DiskFileSystem>(same pattern asto_sys_path()) and reads the counterProvide environment information
Operating System: Platform: darwin Arch: arm64 Version: Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:40 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T6000 Available memory (MB): 65536 Available CPU cores: 10 Binaries: Node: 24.13.0 npm: 11.6.2 Yarn: 1.22.22 pnpm: 10.17.1 Relevant Packages: next: 16.1.6 // Latest available version is detected (16.1.6). eslint-config-next: N/A react: 19.2.3 react-dom: 19.2.3 typescript: 5.9.3 Next.js Config: output: N/AWhich area(s) are affected? (Select all that apply)
Turbopack
Which stage(s) are affected? (Select all that apply)
next dev (local)
Additional context
Every other major bundler gives plugins some way to know what changed in the current pass:
this._compilationobject shared across loader callsthis._compilationobject shared across loader callshandleHotUpdatepasses the changed modules to plugins