Skip to content

ReactFlightWebpackPlugin: use dependency type to build manifest, reducing unnecessary chunk preloads #22

@AbanoubGhadban

Description

@AbanoubGhadban

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:

compilation.chunkGroups.forEach(function (chunkGroup) {
    const chunks = [/* all chunks in this group */];

    function recordModule(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:

Button.js → [shared-chunk, button-deps, settings-deps, main-bundle, vendor]

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:

class ClientReferenceDependency extends ModuleDependency {
    get type() { return 'client-reference'; }
}

And it attaches each one inside an AsyncDependenciesBlock, which creates exactly one chunk group per client component:

const block = new AsyncDependenciesBlock(...);
block.addDependency(dep);   // dep is a ClientReferenceDependency
module.addBlock(block);

Webpack provides APIs to walk backwards from a chunk group to the block that created it, and from that block to its dependencies:

chunkGroup.getBlocks()        // → AsyncDependenciesBlock[]
block.dependencies             // → Dependency[] (contains our ClientReferenceDependency)

Instead of looping through all chunk groups and checking file paths, we can filter chunk groups by dependency type:

compilation.chunkGroups.forEach(function (chunkGroup) {
    const blocks = chunkGroup.getBlocks();
    const clientRefDep = null;

    for (const block of blocks) {
        for (const dep of block.dependencies) {
            if (dep instanceof ClientReferenceDependency) {
                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)
    const chunks = [];
    chunkGroup.chunks.forEach(function (c) {
        for (const file of c.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 needed
    chunkGroup.chunks.forEach(function (chunk) {
        const chunkModules = compilation.chunkGraph.getChunkModulesIterable(chunk);
        Array.from(chunkModules).forEach(function (module) {
            const moduleId = compilation.chunkGraph.getModuleId(module);
            // We can use clientRefDep.request to get the file path directly
            // from the dependency, instead of scanning all modules
            recordModule(moduleId, module);
        });
    });
});

Benefits

  1. 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.

  2. No need for the merge logic. Since each client component has exactly one ClientReferenceDependency → one AsyncDependenciesBlock → 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.

  3. 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.

  4. Addresses the upstream React TODO. This is exactly what the TODO comment in React's source asks for:

    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.

Related

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions