Skip to content

Expose this._compilation to webpack loaders in Turbopack #89599

@jantimon

Description

@jantimon

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    TurbopackRelated to Turbopack with Next.js.

    Type

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions