From 47f683b9715abed502e2a6a770472747a8bc226c Mon Sep 17 00:00:00 2001 From: shkumbinhasani <34962865+shkumbinhasani@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:30:01 +0200 Subject: [PATCH] fix(router-plugin): work out shared bindings when a split chunk compiles first Split and shared modules read the bindings their route shares from a map that compiling the route file fills. When a `?tsr-split=` or `?tsr-shared=1` module compiled first in a process, as when a dev server serves the route file's output from a cache, the map was empty: the chunk declared its own copy of the shared bindings, and the shared module was left as the untouched route file. `getSharedBindings` now computes them from the module's source with the same groupings the route file uses (`getSplitGroupings`, moved out of the reference handler) and records them. The reference compile also records routes without shared bindings, so chunks of those routes don't compute them again. Co-Authored-By: Claude Opus 5.5 --- .../shared-bindings-before-route-file.md | 5 ++ .../ARCHITECTURE-CODE-SPLITTING.md | 20 ++++-- .../src/core/router-code-splitter-plugin.ts | 69 ++++++++++++++----- .../code-splitter-plugin-pipeline.test.ts | 57 +++++++++++++++ 4 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 .changeset/shared-bindings-before-route-file.md diff --git a/.changeset/shared-bindings-before-route-file.md b/.changeset/shared-bindings-before-route-file.md new file mode 100644 index 00000000000..19dd7371095 --- /dev/null +++ b/.changeset/shared-bindings-before-route-file.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-plugin': patch +--- + +Work out a route's shared bindings when a split chunk (`?tsr-split=`) or the shared module (`?tsr-shared=1`) compiles before the route file, as when the route file's output comes from a cache. Before, the chunk declared its own copy of the shared bindings and the shared module was left as the untouched route file. diff --git a/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md b/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md index b3016930784..fb1d916669e 100644 --- a/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md +++ b/packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md @@ -155,7 +155,7 @@ Excludes file IDs that include `tsr-split` or `tsr-shared`. 1. Extracts the grouping from the `?tsr-split=` query parameter 2. Decodes it via `decodeIdentifier()` to get the list of properties this virtual file should export (e.g., `['component']`) -3. Reads shared bindings from `sharedBindingsMap` for the base file +3. Gets the base file's shared bindings from `getSharedBindings()` 4. Calls `compileCodeSplitVirtualRoute()` which: - Removes shared binding declarations before traversal (prevents scope collision crashes) @@ -177,7 +177,7 @@ Excludes file IDs that include `tsr-split` or `tsr-shared`. **What it does:** 1. Extracts the base file path from the ID -2. Looks up shared bindings from `sharedBindingsMap` +2. Gets the base file's shared bindings from `getSharedBindings()` 3. Calls `compileCodeSplitSharedRoute()` which: - Keeps only declarations of shared bindings and their transitive dependencies @@ -416,14 +416,18 @@ The three plugins share a closure containing `sharedBindingsMap`: ``` Plugin 1 (reference): computeSharedBindings() → sharedBindingsMap.set(id, bindings) -Plugin 2 (virtual): sharedBindingsMap.get(baseId) → pass to compileCodeSplitVirtualRoute() -Plugin 3 (shared): sharedBindingsMap.get(baseId) → pass to compileCodeSplitSharedRoute() +Plugin 2 (virtual): getSharedBindings(baseId, code) → pass to compileCodeSplitVirtualRoute() +Plugin 3 (shared): getSharedBindings(baseId, code) → pass to compileCodeSplitSharedRoute() ``` This works because all three plugins run in the same JavaScript process and the map is captured by closure. In typical builds, the reference module is transformed first and then the bundler resolves the emitted dynamic imports, -which triggers transformations for the virtual/shared module IDs. +which triggers transformations for the virtual/shared module IDs. When a +virtual or shared module is transformed first, for example because a dev +server serves the reference module's output from a cache, +`getSharedBindings()` computes the bindings from that module's source with +the same groupings and records them in the map. --- @@ -844,8 +848,10 @@ useless). ### 8. Plugin Ordering in the Array Matters The 3 plugins must be returned in order: reference, virtual, shared. The -reference plugin populates `sharedBindingsMap` before the other two consume -it. Bundlers process transforms in plugin array order for a given module ID. +reference plugin usually populates `sharedBindingsMap` before the other two +read it; when it has not, they compute the bindings themselves (see +Cross-Plugin Communication). Bundlers process transforms in plugin array +order for a given module ID. ### 9. Imported Bindings Are Never Shared diff --git a/packages/router-plugin/src/core/router-code-splitter-plugin.ts b/packages/router-plugin/src/core/router-code-splitter-plugin.ts index 596bd6e0120..b3bc28bd90a 100644 --- a/packages/router-plugin/src/core/router-code-splitter-plugin.ts +++ b/packages/router-plugin/src/core/router-code-splitter-plugin.ts @@ -113,7 +113,8 @@ export function createRouterCodeSplitterPlugin( ) } // Map from normalized route file path → set of shared binding names. - // Populated by the reference compiler, consumed by virtual and shared compilers. + // Populated by the reference compiler, or by `getSharedBindings` when a + // split or shared module compiles first. const sharedBindingsMap = new Map>() const getGlobalCodeSplitGroupings = () => { @@ -126,13 +127,11 @@ export function createRouterCodeSplitterPlugin( return userConfig.codeSplittingOptions?.splitBehavior } - const handleCompilingReferenceFile = ( + const getSplitGroupings = ( code: string, id: string, - generatorNodeInfo: GetRoutesByFileMapResultValue, - ): UnpluginTransformResult => { - if (debug) console.info('Compiling Route: ', id) - + routeId: string, + ): CodeSplitGroupings => { const fromCode = detectCodeSplitGroupingsFromRoute({ code, filename: id, @@ -151,7 +150,7 @@ export function createRouterCodeSplitterPlugin( const userShouldSplitFn = getShouldSplitFn() const pluginSplitBehavior = userShouldSplitFn?.({ - routeId: generatorNodeInfo.routeId, + routeId, }) as CodeSplitGroupings | undefined if (pluginSplitBehavior) { @@ -164,8 +163,50 @@ export function createRouterCodeSplitterPlugin( } } - const splitGroupings: CodeSplitGroupings = + return ( fromCode.groupings ?? pluginSplitBehavior ?? getGlobalCodeSplitGroupings() + ) + } + + /** + * The bindings a route file shares with its split and `?tsr-shared=1` + * modules. A split or shared module compiled before its route file in this + * process (say the route file's output came from a cache) works them out + * from the same source, with the same groupings. + */ + const getSharedBindings = (baseId: string, code: string) => { + let sharedBindings = sharedBindingsMap.get(baseId) + if (!sharedBindings) { + const generatorNodeInfo = routerPluginContext.routesByFile.get(baseId) + if (!generatorNodeInfo) { + return undefined + } + sharedBindings = computeSharedBindings({ + code, + filename: baseId, + codeSplitGroupings: getSplitGroupings( + code, + baseId, + generatorNodeInfo.routeId, + ), + }) + sharedBindingsMap.set(baseId, sharedBindings) + } + return sharedBindings.size > 0 ? sharedBindings : undefined + } + + const handleCompilingReferenceFile = ( + code: string, + id: string, + generatorNodeInfo: GetRoutesByFileMapResultValue, + ): UnpluginTransformResult => { + if (debug) console.info('Compiling Route: ', id) + + const splitGroupings = getSplitGroupings( + code, + id, + generatorNodeInfo.routeId, + ) // Compute shared bindings before compiling the reference route const sharedBindings = computeSharedBindings({ @@ -173,11 +214,7 @@ export function createRouterCodeSplitterPlugin( filename: id, codeSplitGroupings: splitGroupings, }) - if (sharedBindings.size > 0) { - sharedBindingsMap.set(id, sharedBindings) - } else { - sharedBindingsMap.delete(id) - } + sharedBindingsMap.set(id, sharedBindings) const compiledReferenceRoute = compileCodeSplitReferenceRoute({ code, @@ -234,7 +271,7 @@ export function createRouterCodeSplitterPlugin( ) as Array const baseId = id.split('?')[0]! - const resolvedSharedBindings = sharedBindingsMap.get(baseId) + const resolvedSharedBindings = getSharedBindings(baseId, code) const result = compileCodeSplitVirtualRoute({ code, @@ -382,8 +419,8 @@ export function createRouterCodeSplitterPlugin( if (!baseId) return null - const sharedBindings = sharedBindingsMap.get(baseId) - if (!sharedBindings || sharedBindings.size === 0) return null + const sharedBindings = getSharedBindings(baseId, code) + if (!sharedBindings) return null if (debug) console.info('Compiling Shared Module: ', id) diff --git a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts index a0a0eeed679..ec83d659bb5 100644 --- a/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts +++ b/packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts @@ -73,6 +73,63 @@ export const Route = createFileRoute('/unshared')({ component: () =>

}) ) }) + // A dev server that serves the route file's output from a cache compiles a + // split chunk or the shared module before the route file. + const orderCases: Array<{ + name: string + options: Partial + chunk: string + }> = [ + { name: 'the default groupings', options: {}, chunk: 'component' }, + { + name: 'splitBehavior groupings', + options: { + codeSplittingOptions: { + splitBehavior: () => [['loader'], ['component', 'errorComponent']], + }, + }, + chunk: 'component---errorComponent', + }, + ] + + it.each(orderCases)( + 'compiles the split and shared modules with $name the same way before the route file', + async ({ options, chunk }) => { + const file = routeFile('cached') + const code = routeSource('cached') + const ids = { + reference: file, + chunk: `${file}?tsr-split=${chunk}`, + shared: `${file}?tsr-shared=1`, + } + const compile = async (order: Array) => { + const splitter = await createCodeSplitterTransforms(options, { + [file]: '/cached', + }) + const transforms = { + reference: splitter.reference, + chunk: splitter.virtual, + shared: splitter.shared, + } + const modules: Partial> = {} + for (const name of order) { + modules[name] = transforms[name](code, ids[name]) + } + return modules + } + + const routeFirst = await compile(['reference', 'chunk', 'shared']) + expect(importSources(routeFirst.chunk!)).toContain(ids.shared) + expect(routeFirst.shared).toContain('"cached"') + expect(await compile(['chunk', 'shared', 'reference'])).toEqual( + routeFirst, + ) + expect(await compile(['shared', 'chunk', 'reference'])).toEqual( + routeFirst, + ) + }, + ) + it('splits by the plugin-level splitBehavior for the route id', async () => { const file = routeFile('behavior') const code = routeSource('behavior')