diff --git a/.changeset/shared-bindings-before-route-file.md b/.changeset/shared-bindings-before-route-file.md new file mode 100644 index 0000000000..19dd737109 --- /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 b301693078..fb1d916669 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 596bd6e012..b3bc28bd90 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 a0a0eeed67..ec83d659bb 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')