Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/shared-bindings-before-route-file.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 13 additions & 7 deletions packages/router-plugin/ARCHITECTURE-CODE-SPLITTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ Excludes file IDs that include `tsr-split` or `tsr-shared`.
1. Extracts the grouping from the `?tsr-split=<encoded>` 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)
Expand All @@ -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
Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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

Expand Down
69 changes: 53 additions & 16 deletions packages/router-plugin/src/core/router-code-splitter-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Set<string>>()

const getGlobalCodeSplitGroupings = () => {
Expand All @@ -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,
Expand All @@ -151,7 +150,7 @@ export function createRouterCodeSplitterPlugin(
const userShouldSplitFn = getShouldSplitFn()

const pluginSplitBehavior = userShouldSplitFn?.({
routeId: generatorNodeInfo.routeId,
routeId,
}) as CodeSplitGroupings | undefined

if (pluginSplitBehavior) {
Expand All @@ -164,20 +163,58 @@ 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({
code,
filename: id,
codeSplitGroupings: splitGroupings,
})
if (sharedBindings.size > 0) {
sharedBindingsMap.set(id, sharedBindings)
} else {
sharedBindingsMap.delete(id)
}
sharedBindingsMap.set(id, sharedBindings)

const compiledReferenceRoute = compileCodeSplitReferenceRoute({
code,
Expand Down Expand Up @@ -234,7 +271,7 @@ export function createRouterCodeSplitterPlugin(
) as Array<SplitRouteIdentNodes>

const baseId = id.split('?')[0]!
const resolvedSharedBindings = sharedBindingsMap.get(baseId)
const resolvedSharedBindings = getSharedBindings(baseId, code)

const result = compileCodeSplitVirtualRoute({
code,
Expand Down Expand Up @@ -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)

Expand Down
57 changes: 57 additions & 0 deletions packages/router-plugin/tests/code-splitter-plugin-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,63 @@ export const Route = createFileRoute('/unshared')({ component: () => <p /> })
)
})

// 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<Config>
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<keyof typeof ids>) => {
const splitter = await createCodeSplitterTransforms(options, {
[file]: '/cached',
})
const transforms = {
reference: splitter.reference,
chunk: splitter.virtual,
shared: splitter.shared,
}
const modules: Partial<Record<keyof typeof ids, string | null>> = {}
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')
Expand Down
Loading