Skip to content

Commit a11f578

Browse files
KyleAMathewsclaude
andcommitted
fix(router-plugin): prevent double initialization of shared module-level variables
When module-level variables (like TanStack collections) were used in both loader and component, they were initialized twice due to code splitting: once in the reference file and once in the split component file. This fix: - Detects shared module-level const/let variables used by both split and non-split properties - Exports them from the reference file - Imports them in split files instead of duplicating Fixes double initialization issue reported in Discord where collections and queries defined at route module level were being created twice. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6baffeb commit a11f578

23 files changed

+528
-0
lines changed

packages/router-plugin/src/core/code-splitter/compilers.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export function compileCodeSplitReferenceRoute(
118118
const refIdents = findReferencedIdentifiers(ast)
119119

120120
const knownExportedIdents = new Set<string>()
121+
const sharedModuleLevelIdents = new Set<string>()
121122

122123
function findIndexForSplitNode(str: string) {
123124
return opts.codeSplitGroupings.findIndex((group) =>
@@ -165,6 +166,52 @@ export function compileCodeSplitReferenceRoute(
165166
return programPath.scope.hasBinding(name)
166167
}
167168

169+
// Helper to collect all identifiers referenced by a node
170+
const collectReferencedIdentifiers = (propPath: babel.NodePath<t.ObjectProperty>): Set<string> => {
171+
const identifiers = new Set<string>()
172+
const valuePath = propPath.get('value')
173+
174+
// If the value is an identifier, we need to follow it to its definition
175+
const pathsToAnalyze: Array<babel.NodePath> = []
176+
177+
if (valuePath.isIdentifier()) {
178+
const binding = programPath.scope.getBinding(valuePath.node.name)
179+
if (binding) {
180+
pathsToAnalyze.push(binding.path)
181+
}
182+
} else {
183+
pathsToAnalyze.push(valuePath)
184+
}
185+
186+
// Traverse each path to find all referenced identifiers
187+
pathsToAnalyze.forEach(analyzePath => {
188+
analyzePath.traverse({
189+
Identifier(idPath) {
190+
// Only collect identifiers that are references (not declarations)
191+
if (!idPath.isReferencedIdentifier()) return
192+
193+
const name = idPath.node.name
194+
const binding = programPath.scope.getBinding(name)
195+
196+
// Only include identifiers that are defined at module level
197+
if (binding) {
198+
const declarationPath = binding.path
199+
// Check if this is a top-level variable declaration
200+
if (
201+
declarationPath.isVariableDeclarator() &&
202+
declarationPath.parentPath?.isVariableDeclaration() &&
203+
declarationPath.parentPath.parentPath?.isProgram()
204+
) {
205+
identifiers.add(name)
206+
}
207+
}
208+
},
209+
})
210+
})
211+
212+
return identifiers
213+
}
214+
168215
if (t.isObjectExpression(routeOptions)) {
169216
if (opts.deleteNodes && opts.deleteNodes.size > 0) {
170217
routeOptions.properties = routeOptions.properties.filter(
@@ -191,6 +238,41 @@ export function compileCodeSplitReferenceRoute(
191238
// exit traversal so this route is not split
192239
return programPath.stop()
193240
}
241+
242+
// First pass: collect identifiers used by split vs non-split properties
243+
const splitPropertyIdents = new Set<string>()
244+
const nonSplitPropertyIdents = new Set<string>()
245+
246+
// We need to analyze the route options object to find shared identifiers
247+
// Since routeOptions might be a resolved node, we traverse from programPath
248+
// to find all ObjectProperty paths and analyze them
249+
programPath.traverse({
250+
ObjectProperty(propPath) {
251+
// Check if this property belongs to the route options by checking its parent
252+
if (!t.isObjectExpression(propPath.parent)) return
253+
if (propPath.parent !== routeOptions) return
254+
if (!t.isIdentifier(propPath.node.key)) return
255+
256+
const key = propPath.node.key.name
257+
const willBeSplit = findIndexForSplitNode(key) !== -1 && SPLIT_NODES_CONFIG.has(key as any)
258+
259+
const idents = collectReferencedIdentifiers(propPath)
260+
261+
if (willBeSplit) {
262+
idents.forEach(id => splitPropertyIdents.add(id))
263+
} else {
264+
idents.forEach(id => nonSplitPropertyIdents.add(id))
265+
}
266+
},
267+
})
268+
269+
// Find shared identifiers that need to be exported
270+
splitPropertyIdents.forEach(ident => {
271+
if (nonSplitPropertyIdents.has(ident)) {
272+
sharedModuleLevelIdents.add(ident)
273+
}
274+
})
275+
194276
routeOptions.properties.forEach((prop) => {
195277
if (t.isObjectProperty(prop)) {
196278
if (t.isIdentifier(prop.key)) {
@@ -423,6 +505,41 @@ export function compileCodeSplitReferenceRoute(
423505
},
424506
})
425507
}
508+
509+
/**
510+
* Export shared module-level variables that are used by both
511+
* split properties (e.g., component) and non-split properties (e.g., loader)
512+
* This prevents double initialization when the split file is loaded
513+
*/
514+
if (sharedModuleLevelIdents.size > 0) {
515+
modified = true
516+
sharedModuleLevelIdents.forEach((identName) => {
517+
const binding = programPath.scope.getBinding(identName)
518+
if (!binding) return
519+
520+
const bindingPath = binding.path
521+
522+
// Check if it's a variable declaration at the top level
523+
if (
524+
bindingPath.isVariableDeclarator() &&
525+
bindingPath.parentPath?.isVariableDeclaration() &&
526+
bindingPath.parentPath.parentPath?.isProgram()
527+
) {
528+
const varDecl = bindingPath.parentPath
529+
530+
// Only export const/let declarations (not imports or functions)
531+
if (
532+
varDecl.node.kind === 'const' ||
533+
varDecl.node.kind === 'let'
534+
) {
535+
// Convert to export declaration
536+
const exportDecl = t.exportNamedDeclaration(varDecl.node, [])
537+
varDecl.replaceWith(exportDecl)
538+
knownExportedIdents.add(identName)
539+
}
540+
}
541+
})
542+
}
426543
},
427544
},
428545
})
@@ -762,6 +879,107 @@ export function compileCodeSplitVirtualRoute(
762879
}
763880
},
764881
})
882+
883+
// Convert non-exported module-level variables that are shared with the reference file
884+
// to imports. These are variables used by both split and non-split parts.
885+
// They will be exported in the reference file, so we need to import them here.
886+
const importedSharedIdents = new Set<string>()
887+
const sharedVariablesToImport: Array<string> = []
888+
889+
programPath.traverse({
890+
VariableDeclaration(varDeclPath) {
891+
// Only process top-level const/let declarations
892+
if (!varDeclPath.parentPath.isProgram()) return
893+
if (varDeclPath.node.kind !== 'const' && varDeclPath.node.kind !== 'let') return
894+
895+
varDeclPath.node.declarations.forEach((declarator) => {
896+
if (!t.isIdentifier(declarator.id)) return
897+
898+
const varName = declarator.id.name
899+
900+
// Skip if the init is a function/arrow function - those are unlikely to be shared objects
901+
// We only want to import object literals and similar data structures
902+
if (
903+
t.isArrowFunctionExpression(declarator.init) ||
904+
t.isFunctionExpression(declarator.init)
905+
) {
906+
return
907+
}
908+
909+
// Check if this variable is used by the split node
910+
// If it is, it might be shared with non-split parts and should be imported
911+
const isUsedBySplitNode = intendedSplitNodes.values().next().value &&
912+
Object.values(trackedNodesToSplitByType).some(tracked => {
913+
if (!tracked?.node) return false
914+
let isUsed = false
915+
babel.traverse(tracked.node, {
916+
Identifier(idPath) {
917+
if (idPath.isReferencedIdentifier() && idPath.node.name === varName) {
918+
isUsed = true
919+
}
920+
}
921+
}, programPath.scope)
922+
return isUsed
923+
})
924+
925+
if (isUsedBySplitNode) {
926+
sharedVariablesToImport.push(varName)
927+
}
928+
})
929+
},
930+
})
931+
932+
// Remove shared variable declarations and add imports
933+
if (sharedVariablesToImport.length > 0) {
934+
programPath.traverse({
935+
VariableDeclaration(varDeclPath) {
936+
if (!varDeclPath.parentPath.isProgram()) return
937+
938+
const declaratorsToKeep = varDeclPath.node.declarations.filter((declarator) => {
939+
if (!t.isIdentifier(declarator.id)) return true
940+
const varName = declarator.id.name
941+
942+
if (sharedVariablesToImport.includes(varName)) {
943+
importedSharedIdents.add(varName)
944+
return false // Remove this declarator
945+
}
946+
return true
947+
})
948+
949+
if (declaratorsToKeep.length === 0) {
950+
// Remove the entire variable declaration
951+
varDeclPath.remove()
952+
} else if (declaratorsToKeep.length < varDeclPath.node.declarations.length) {
953+
// Update with remaining declarators
954+
varDeclPath.node.declarations = declaratorsToKeep
955+
}
956+
},
957+
})
958+
959+
// Add import statement for shared variables
960+
if (importedSharedIdents.size > 0) {
961+
const importDecl = t.importDeclaration(
962+
Array.from(importedSharedIdents).map((name) =>
963+
t.importSpecifier(t.identifier(name), t.identifier(name)),
964+
),
965+
t.stringLiteral(removeSplitSearchParamFromFilename(opts.filename)),
966+
)
967+
programPath.unshiftContainer('body', importDecl)
968+
969+
// Track imported identifiers for dead code elimination
970+
const importPath = programPath.get('body')[0] as babel.NodePath
971+
importPath.traverse({
972+
Identifier(identPath) {
973+
if (
974+
identPath.parentPath.isImportSpecifier() &&
975+
identPath.key === 'local'
976+
) {
977+
refIdents.add(identPath)
978+
}
979+
},
980+
})
981+
}
982+
}
765983
},
766984
},
767985
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
console.warn("[tanstack-router] These exports from \"shared-module-variable.tsx\" will not be code-split and will increase your bundle size:\n- collection\nFor the best optimization, these items should either have their export statements removed, or be imported from another location that is not a route file.");
2+
const $$splitComponentImporter = () => import('shared-module-variable.tsx?tsr-split=component');
3+
import { lazyRouteComponent } from '@tanstack/react-router';
4+
import { createFileRoute } from '@tanstack/react-router';
5+
6+
// Module-level variable used in both loader and component
7+
// This simulates a collection/query that should only be initialized once
8+
export const collection = {
9+
name: 'todos',
10+
preload: async () => {}
11+
}; // Side effect at module level - should only run once
12+
console.log('Module initialized:', collection.name);
13+
export const Route = createFileRoute('/todos')({
14+
loader: async () => {
15+
// Use collection in loader
16+
await collection.preload();
17+
return {
18+
data: 'loaded'
19+
};
20+
},
21+
component: lazyRouteComponent($$splitComponentImporter, 'component')
22+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { collection } from "shared-module-variable.tsx";
2+
// Module-level variable used in both loader and component
3+
// This simulates a collection/query that should only be initialized once
4+
5+
// Side effect at module level - should only run once
6+
console.log('Module initialized:', collection.name);
7+
function TodosComponent() {
8+
// Use collection in component
9+
return <div>{collection.name}</div>;
10+
}
11+
export { TodosComponent as component };
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { collection } from "shared-module-variable.tsx";
2+
// Module-level variable used in both loader and component
3+
// This simulates a collection/query that should only be initialized once
4+
5+
// Side effect at module level - should only run once
6+
console.log('Module initialized:', collection.name);
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { collection } from "shared-module-variable.tsx";
2+
// Module-level variable used in both loader and component
3+
// This simulates a collection/query that should only be initialized once
4+
5+
// Side effect at module level - should only run once
6+
console.log('Module initialized:', collection.name);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const $$splitComponentImporter = () => import('shared-module-variable.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent');
2+
import { lazyRouteComponent } from '@tanstack/react-router';
3+
const $$splitLoaderImporter = () => import('shared-module-variable.tsx?tsr-split=loader');
4+
import { lazyFn } from '@tanstack/react-router';
5+
import { createFileRoute } from '@tanstack/react-router';
6+
7+
// Module-level variable used in both loader and component
8+
// This simulates a collection/query that should only be initialized once
9+
const collection = {
10+
name: 'todos',
11+
preload: async () => {}
12+
};
13+
14+
// Side effect at module level - should only run once
15+
console.log('Module initialized:', collection.name);
16+
export const Route = createFileRoute('/todos')({
17+
loader: lazyFn($$splitLoaderImporter, 'loader'),
18+
component: lazyRouteComponent($$splitComponentImporter, 'component')
19+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { collection } from "shared-module-variable.tsx";
2+
// Module-level variable used in both loader and component
3+
// This simulates a collection/query that should only be initialized once
4+
5+
// Side effect at module level - should only run once
6+
console.log('Module initialized:', collection.name);
7+
function TodosComponent() {
8+
// Use collection in component
9+
return <div>{collection.name}</div>;
10+
}
11+
export { TodosComponent as component };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { collection } from "shared-module-variable.tsx";
2+
// Module-level variable used in both loader and component
3+
// This simulates a collection/query that should only be initialized once
4+
5+
// Side effect at module level - should only run once
6+
console.log('Module initialized:', collection.name);
7+
const SplitLoader = async () => {
8+
// Use collection in loader
9+
await collection.preload();
10+
return {
11+
data: 'loaded'
12+
};
13+
};
14+
export { SplitLoader as loader };
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
const $$splitComponentImporter = () => import('shared-module-variable.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent');
2+
import { lazyRouteComponent } from '@tanstack/react-router';
3+
const $$splitLoaderImporter = () => import('shared-module-variable.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent');
4+
import { lazyFn } from '@tanstack/react-router';
5+
import { createFileRoute } from '@tanstack/react-router';
6+
7+
// Module-level variable used in both loader and component
8+
// This simulates a collection/query that should only be initialized once
9+
const collection = {
10+
name: 'todos',
11+
preload: async () => {}
12+
};
13+
14+
// Side effect at module level - should only run once
15+
console.log('Module initialized:', collection.name);
16+
export const Route = createFileRoute('/todos')({
17+
loader: lazyFn($$splitLoaderImporter, 'loader'),
18+
component: lazyRouteComponent($$splitComponentImporter, 'component')
19+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { collection } from "shared-module-variable.tsx";
2+
// Module-level variable used in both loader and component
3+
// This simulates a collection/query that should only be initialized once
4+
5+
// Side effect at module level - should only run once
6+
console.log('Module initialized:', collection.name);
7+
function TodosComponent() {
8+
// Use collection in component
9+
return <div>{collection.name}</div>;
10+
}
11+
const SplitLoader = async () => {
12+
// Use collection in loader
13+
await collection.preload();
14+
return {
15+
data: 'loaded'
16+
};
17+
};
18+
export { SplitLoader as loader };
19+
export { TodosComponent as component };

0 commit comments

Comments
 (0)