nextjs - ✅(Solved) Fix Expose this._compilation to webpack loaders in Turbopack [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
vercel/next.js#89599Fetched 2026-04-08 02:02:36
View on GitHub
Comments
0
Participants
1
Timeline
4
Reactions
1
Author
Participants
Timeline (top)
cross-referenced ×1issue_type_added ×1labeled ×1subscribed ×1

Fix Action

Fixed

PR fix notes

PR #89600: expose this._compilation to webpack loaders in Turbopack

Description (problem / solution / changelog)

This adds this._compilation to the webpack loader context in turbopack.

In webpack, loaders can use this._compilation as a WeakMap key to cache expensive work for the duration of a compilation. This is useful for loaders that evaluate shared dependencies — e.g. next-yak evaluates design token files for every component that uses them, and without per-compilation caching the same file gets evaluated once per dependent component per batch.

The implementation piggybacks on the existing file watcher batching. A u64 counter on DiskFileSystemInner increments on each batch flush and gets passed through the IPC Evaluate message to the Node.js loader process. The counter is deliberately kept out of WebpackLoaderContext.args so it doesn't affect turbo-tasks memoization. On the JS side, a new frozen object is created whenever the counter changes.

Includes an e2e test that verifies the object is shared within a batch and changes between batches.

Fixes #89599

Changed files

  • test/development/app-dir/loader-compilation-counter/app/layout.tsx (added, +8/-0)
  • test/development/app-dir/loader-compilation-counter/app/page.tsx (added, +16/-0)
  • test/development/app-dir/loader-compilation-counter/counter-loader.js (added, +32/-0)
  • test/development/app-dir/loader-compilation-counter/lib/a.ts (added, +3/-0)
  • test/development/app-dir/loader-compilation-counter/lib/b.ts (added, +3/-0)
  • test/development/app-dir/loader-compilation-counter/lib/token.json (added, +1/-0)
  • test/development/app-dir/loader-compilation-counter/loader-compilation-counter.test.ts (added, +68/-0)
  • test/development/app-dir/loader-compilation-counter/next.config.js (added, +23/-0)
  • turbopack/crates/turbo-tasks-fs/src/lib.rs (modified, +22/-1)
  • turbopack/crates/turbo-tasks-fs/src/watcher.rs (modified, +2/-0)
  • turbopack/crates/turbopack-node/js/src/ipc/evaluate.ts (modified, +8/-5)
  • turbopack/crates/turbopack-node/js/src/transforms/webpack-loaders.ts (modified, +22/-0)
  • turbopack/crates/turbopack-node/src/evaluate.rs (modified, +13/-2)
  • turbopack/crates/turbopack-node/src/transforms/webpack.rs (modified, +24/-0)

Code Example

button.tsx    → imports spacings.ts
card.tsx      → imports spacings.ts
accordion.tsx → imports spacings.ts

---

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)
}

---

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
RAW_BUFFERClick to expand / collapse

See PR https://github.com/vercel/next.js/pull/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:

BundlerHow plugins know what changed
webpackthis._compilation object shared across loader calls
rspackthis._compilation object shared across loader calls
vitehandleHotUpdate passes the changed modules to plugins
turbopack⛔️ not possible

extent analysis

TL;DR

Implementing a shared this._compilation object in turbopack, similar to webpack, would allow loaders to cache shared work across files within a single pass.

Guidance

  • To address the issue, introduce a this._compilation object in turbopack that is shared across loader invocations within the same file-watcher batch.
  • This object can be used as a WeakMap key for per-compilation caching, enabling loaders to avoid redundant work.
  • The this._compilation object should be created outside of WebpackLoaderContext.args to avoid busting turbo-tasks' memoization cache.
  • The EvaluateContext trait can be updated with an async compilation_marker() method to resolve the filesystem and read the counter.

Example

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)
}

Notes

The proposed solution relies on the this._compilation object being shared across loader invocations within the same file-watcher batch. If this object is not properly implemented, loaders may still perform redundant work.

Recommendation

Apply the proposed workaround by introducing a shared this._compilation object in turbopack, allowing loaders to cache shared work across files within a single pass. This approach enables efficient caching and avoids redundant work, making it a suitable solution for the described issue.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

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

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING