Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/next/src/server/dev/hot-reloader-turbopack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,13 @@ export async function createHotReloaderTurbopack(
p.startsWith('server/app')
)

// Edge uses the browser runtime which already disposes chunks individually.
// TODO: process.env.NEXT_RUNTIME is 'nodejs' even though Node.js runtime is not used.
if ('__turbopack_clear_chunk_cache__' in globalThis) {
;(globalThis as any).__turbopack_clear_chunk_cache__()
}
Comment on lines +350 to +353
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge doesn't implement __turbopack_load_by_url__ so we can ignore for now. But this needs clarification in a follow-up since it's confusing that process.env.NEXT_RUNTIME is 'nodejs' while Turbopack doesn't use the Node.js runtime.


// TODO: Stop re-evaluating React Client once it relies on Turbopack's chunk cache.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (hasAppPaths) {
deleteFromRequireCache(
require.resolve(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,35 +193,43 @@ async function loadChunk(
return promise
}

async function loadChunkByUrl(source: SourceInfo, chunkUrl: ChunkUrl) {
try {
await BACKEND.loadChunk(chunkUrl, source)
} catch (error) {
let loadReason
switch (source.type) {
case SourceType.Runtime:
loadReason = `as a runtime dependency of chunk ${source.chunkPath}`
break
case SourceType.Parent:
loadReason = `from module ${source.parentId}`
break
case SourceType.Update:
loadReason = 'from an HMR update'
break
default:
invariant(source, (source) => `Unknown source type: ${source?.type}`)
}
throw new Error(
`Failed to load chunk ${chunkUrl} ${loadReason}${
error ? `: ${error}` : ''
}`,
error
? {
cause: error,
}
: undefined
)
const instrumentedBackendLoadChunks = new WeakMap<Promise<any>, Promise<any>>()
// Do not make this async. React relies on referential equality of the returned Promise.
function loadChunkByUrl(source: SourceInfo, chunkUrl: ChunkUrl): Promise<any> {
const thenable = BACKEND.loadChunk(chunkUrl, source)
let entry = instrumentedBackendLoadChunks.get(thenable)
if (entry === undefined) {
entry = thenable.catch((error) => {
let loadReason
switch (source.type) {
case SourceType.Runtime:
loadReason = `as a runtime dependency of chunk ${source.chunkPath}`
break
case SourceType.Parent:
loadReason = `from module ${source.parentId}`
break
case SourceType.Update:
loadReason = 'from an HMR update'
break
default:
invariant(source, (source) => `Unknown source type: ${source?.type}`)
}
throw new Error(
`Failed to load chunk ${chunkUrl} ${loadReason}${
error ? `: ${error}` : ''
}`,
error
? {
cause: error,
}
: undefined
)
})
// TODO: Free the Promise once it resolves.
instrumentedBackendLoadChunks.set(thenable, entry)
}

return entry
}

async function loadChunkPath(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ function loadChunk(chunkData: ChunkData, source?: SourceInfo): void {
}

const loadedChunks = new Set<ChunkPath>()
const unsupportedLoadChunk = Promise.resolve(undefined)
const loadedChunk = Promise.resolve(undefined)
const chunkCache = new Map<ChunkPath, Promise<any> | null>()
Copy link
Member

@bgw bgw Jul 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand you want to avoid holding onto extra promises, but it does seem a bit confusing (evidenced by Vade's (the AI) wrong comment) that null is used to store the success state.

Why not just store loadedChunk as the map's value instead of null after success?

If you don't want to do that, consider using a sentinel value of true instead of null and/or including a comment explaining the type of chunkCache.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. I'll do that in a follow-up


function clearChunkCache() {
chunkCache.clear()
}

function loadChunkPath(chunkPath: ChunkPath, source?: SourceInfo): void {
if (!isJs(chunkPath)) {
Expand Down Expand Up @@ -136,21 +143,10 @@ function loadChunkPath(chunkPath: ChunkPath, source?: SourceInfo): void {
}
}

async function loadChunkAsync(
async function loadChunkAsyncUncached(
source: SourceInfo,
chunkData: ChunkData
): Promise<any> {
const chunkPath = typeof chunkData === 'string' ? chunkData : chunkData.path
if (!isJs(chunkPath)) {
// We only support loading JS chunks in Node.js.
// This branch can be hit when trying to load a CSS chunk.
return
}

if (loadedChunks.has(chunkPath)) {
return
}

chunkPath: ChunkPath
): Promise<void> {
const resolved = path.resolve(RUNTIME_ROOT, chunkPath)

try {
Expand Down Expand Up @@ -187,7 +183,6 @@ async function loadChunkAsync(
}
}
}
loadedChunks.add(chunkPath)
} catch (e) {
let errorMessage = `Failed to load chunk ${chunkPath}`

Expand All @@ -201,7 +196,30 @@ async function loadChunkAsync(
}
}

async function loadChunkAsyncByUrl(source: SourceInfo, chunkUrl: string) {
function loadChunkAsync(
source: SourceInfo,
chunkData: ChunkData
): Promise<any> {
const chunkPath = typeof chunkData === 'string' ? chunkData : chunkData.path
if (!isJs(chunkPath)) {
// We only support loading JS chunks in Node.js.
// This branch can be hit when trying to load a CSS chunk.
return unsupportedLoadChunk
}

let entry = chunkCache.get(chunkPath)
if (entry === undefined) {
const resolve = chunkCache.set.bind(chunkCache, chunkPath, null)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chunk cache logic in the Node.js runtime has a critical bug where successful chunk loads set the cache entry to null instead of marking it as completed, breaking the caching mechanism.

View Details

Analysis

In the loadChunkAsync function, line 212 creates a resolve function using chunkCache.set.bind(chunkCache, chunkPath, null). This bound function sets the cache entry to null when called. The problem occurs in the promise chain:

  1. loadChunkAsyncUncached(source, chunkPath).then(resolve) - when the chunk loads successfully, it calls resolve()
  2. resolve() executes chunkCache.set(chunkPath, null) - this sets the cache entry to null
  3. Line 219 returns entry === null ? loadedChunk : entry - since entry is now null, it returns loadedChunk (Promise.resolve(undefined))

This means successful chunk loads are not properly cached. The intent appears to be marking the cache entry as "completed" when the chunk finishes loading, but instead it's being set to null, which breaks the caching logic. Subsequent requests for the same chunk will incorrectly return loadedChunk instead of recognizing that the chunk has already been successfully loaded.


Recommendation

The resolve function should mark the cache entry as completed rather than setting it to null. Consider changing line 212 to:

const resolve = () => chunkCache.set(chunkPath, null)

Or better yet, implement a proper completion marker system where null represents "completed" and the promise represents "loading", and update the return logic accordingly to handle this distinction properly.


👍 or 👎 to improve Vade.

// A new Promise ensures callers that don't handle rejection will still trigger one unhandled rejection.
// Handling the rejection will not trigger unhandled rejections.
entry = loadChunkAsyncUncached(source, chunkPath).then(resolve)
chunkCache.set(chunkPath, entry)
}
// TODO: Return an instrumented Promise that React can use instead of relying on referential equality.
return entry === null ? loadedChunk : entry
}

function loadChunkAsyncByUrl(source: SourceInfo, chunkUrl: string) {
const path = url.fileURLToPath(new URL(chunkUrl, RUNTIME_ROOT)) as ChunkPath
return loadChunkAsync(source, path)
}
Expand Down Expand Up @@ -363,6 +381,9 @@ function isJs(chunkUrlOrPath: ChunkUrl | ChunkPath): boolean {
return regexJsUrl.test(chunkUrlOrPath)
}

// For hot-reloader
;(globalThis as any).__turbopack_clear_chunk_cache__ = clearChunkCache
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to declare a global in typescript? e.g.

declare global {
interface Window {
__nextDevClientId: number
}
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not want to expose globals in the Turbopack runtime. You can expose a method on the Turbopack context instead and expose it as global from next.js

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We settled on doing this in a follow-up. It'll be a continuation of a7ceabc (#81663) but instead we expose it on the global from a bundled entrypoint.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


module.exports = {
getOrInstantiateRuntimeModule,
loadChunk,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

Loading