You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Issue #19 identified a bug where the client manifest overwrites chunk lists when a module appears in multiple chunk groups. The fix (merging chunks from all chunk groups) is correct and prevents runtime errors.
However, the merge approach introduces a new inefficiency: the manifest now contains the union of chunks from every chunk group where a client component appears — including chunks that are completely unrelated to that component.
The Problem
The current approach loops through all chunk groups and checks each module's file path against a list of known 'use client' files:
compilation.chunkGroups.forEach(function(chunkGroup){constchunks=[/* all chunks in this group */];functionrecordModule(id,module){if(!resolvedClientFiles.has(module.resource)){return;// check file path against a list}// record or merge chunks...}});
Imagine Button.js ('use client') ends up in a shared chunk due to SplitChunksPlugin. That shared chunk appears in multiple chunk groups:
Chunk group
Created by
Chunks
client0 (Button's own async block)
ClientReferenceDependency for Button.js
[shared-chunk, button-deps]
client5 (SettingsPage's async block)
ClientReferenceDependency for SettingsPage.js
[shared-chunk, settings-deps]
Some entry chunk group
The app's entry point
[shared-chunk, main-bundle, vendor]
After the merge fix, the manifest for Button.js becomes:
The browser will preload all of these when it encounters Button.js in the RSC stream, even though settings-deps, main-bundle, and vendor have nothing to do with Button.js. Only shared-chunk and button-deps are actually needed.
Proposed Solution
The plugin already creates a custom dependency type for each client component:
Instead of looping through all chunk groups and checking file paths, we can filter chunk groups by dependency type:
compilation.chunkGroups.forEach(function(chunkGroup){constblocks=chunkGroup.getBlocks();constclientRefDep=null;for(constblockofblocks){for(constdepofblock.dependencies){if(depinstanceofClientReferenceDependency){clientRefDep=dep;break;}}if(clientRefDep)break;}if(!clientRefDep){return;// Not a client-reference chunk group — skip entirely}// Only collect chunks from THIS chunk group (the one created for this client component)constchunks=[];chunkGroup.chunks.forEach(function(c){for(constfileofc.files){if(!(file.endsWith('.js')||file.endsWith('.mjs')))return;if(file.endsWith('.hot-update.js')||file.endsWith('.hot-update.mjs'))return;chunks.push(c.id,file);break;}});// Record all modules in this chunk group — no file-path check neededchunkGroup.chunks.forEach(function(chunk){constchunkModules=compilation.chunkGraph.getChunkModulesIterable(chunk);Array.from(chunkModules).forEach(function(module){constmoduleId=compilation.chunkGraph.getModuleId(module);// We can use clientRefDep.request to get the file path directly// from the dependency, instead of scanning all modulesrecordModule(moduleId,module);});});});
Benefits
Fewer chunks preloaded by the browser. The manifest would only contain chunks from the chunk group that was specifically created for that client component — the minimal correct set. No unrelated chunks from other chunk groups.
No need for resolvedClientFiles set. The file-path comparison is eliminated entirely. The dependency type itself tells you "this is a client component" — more reliable and more direct.
TODO: Hook into deps instead of the target module.
That way we know by the type of dep whether to include.
It also resolves conflicts when the same module is in multiple chunks.
Risks & Considerations
Need to verify that chunkGroup.getBlocks() and block.dependencies are stable public APIs in webpack 5 (they are used in webpack's own stats and other internal plugins).
Need to confirm that the chunk group created by the AsyncDependenciesBlock always contains the full set of chunks needed (including shared chunks moved by SplitChunksPlugin). Based on how webpack resolves async blocks, this should be the case — the chunk group represents "everything needed to fulfill this async import."
This is an upstream React change. We should test it thoroughly with our setup before proposing it upstream.
Context
Issue #19 identified a bug where the client manifest overwrites chunk lists when a module appears in multiple chunk groups. The fix (merging chunks from all chunk groups) is correct and prevents runtime errors.
However, the merge approach introduces a new inefficiency: the manifest now contains the union of chunks from every chunk group where a client component appears — including chunks that are completely unrelated to that component.
The Problem
The current approach loops through all chunk groups and checks each module's file path against a list of known
'use client'files:Imagine
Button.js('use client') ends up in a shared chunk due toSplitChunksPlugin. That shared chunk appears in multiple chunk groups:client0(Button's own async block)ClientReferenceDependencyfor Button.js[shared-chunk, button-deps]client5(SettingsPage's async block)ClientReferenceDependencyfor SettingsPage.js[shared-chunk, settings-deps][shared-chunk, main-bundle, vendor]After the merge fix, the manifest for
Button.jsbecomes:The browser will preload all of these when it encounters Button.js in the RSC stream, even though
settings-deps,main-bundle, andvendorhave nothing to do with Button.js. Onlyshared-chunkandbutton-depsare actually needed.Proposed Solution
The plugin already creates a custom dependency type for each client component:
And it attaches each one inside an
AsyncDependenciesBlock, which creates exactly one chunk group per client component:Webpack provides APIs to walk backwards from a chunk group to the block that created it, and from that block to its dependencies:
Instead of looping through all chunk groups and checking file paths, we can filter chunk groups by dependency type:
Benefits
Fewer chunks preloaded by the browser. The manifest would only contain chunks from the chunk group that was specifically created for that client component — the minimal correct set. No unrelated chunks from other chunk groups.
No need for the merge logic. Since each client component has exactly one
ClientReferenceDependency→ oneAsyncDependenciesBlock→ one chunk group, you never encounter the same module twice. The overwrite bug (ReactFlightWebpackPlugin: client manifest overwrites chunk lists for shared modules, causing hydration errors on slow networks #19) becomes structurally impossible.No need for
resolvedClientFilesset. The file-path comparison is eliminated entirely. The dependency type itself tells you "this is a client component" — more reliable and more direct.Addresses the upstream React TODO. This is exactly what the TODO comment in React's source asks for:
Risks & Considerations
chunkGroup.getBlocks()andblock.dependenciesare stable public APIs in webpack 5 (they are used in webpack's own stats and other internal plugins).AsyncDependenciesBlockalways contains the full set of chunks needed (including shared chunks moved bySplitChunksPlugin). Based on how webpack resolves async blocks, this should be the case — the chunk group represents "everything needed to fulfill this async import."Related