@tanstack/router-plugin 1.159.11 → 1.159.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@ import {
7
7
  generateFromAst,
8
8
  parseAst,
9
9
  } from '@tanstack/router-utils'
10
- import { tsrSplit } from '../constants'
10
+ import { tsrShared, tsrSplit } from '../constants'
11
11
  import { routeHmrStatement } from '../route-hmr-statement'
12
12
  import { createIdentifier } from './path-ids'
13
13
  import { getFrameworkOptions } from './framework-options'
@@ -94,6 +94,11 @@ function removeSplitSearchParamFromFilename(filename: string) {
94
94
  return bareFilename!
95
95
  }
96
96
 
97
+ export function addSharedSearchParamToFilename(filename: string) {
98
+ const [bareFilename] = filename.split('?')
99
+ return `${bareFilename}?${tsrShared}=1`
100
+ }
101
+
97
102
  const splittableCreateRouteFns = ['createFileRoute']
98
103
  const unsplittableCreateRouteFns = [
99
104
  'createRootRoute',
@@ -104,6 +109,530 @@ const allCreateRouteFns = [
104
109
  ...unsplittableCreateRouteFns,
105
110
  ]
106
111
 
112
+ /**
113
+ * Recursively walk an AST node and collect referenced identifier-like names.
114
+ * Much cheaper than babel.traverse — no path/scope overhead.
115
+ *
116
+ * Notes:
117
+ * - Uses @babel/types `isReferenced` to avoid collecting non-references like
118
+ * object keys, member expression properties, or binding identifiers.
119
+ * - Also handles JSX identifiers for component references.
120
+ */
121
+ export function collectIdentifiersFromNode(node: t.Node): Set<string> {
122
+ const ids = new Set<string>()
123
+
124
+ ;(function walk(
125
+ n: t.Node | null | undefined,
126
+ parent?: t.Node,
127
+ grandparent?: t.Node,
128
+ parentKey?: string,
129
+ ) {
130
+ if (!n) return
131
+
132
+ if (t.isIdentifier(n)) {
133
+ // When we don't have parent info (node passed in isolation), treat as referenced.
134
+ if (!parent || t.isReferenced(n, parent, grandparent)) {
135
+ ids.add(n.name)
136
+ }
137
+ return
138
+ }
139
+
140
+ if (t.isJSXIdentifier(n)) {
141
+ // Skip attribute names: <div data-testid="x" />
142
+ if (parent && t.isJSXAttribute(parent) && parentKey === 'name') {
143
+ return
144
+ }
145
+
146
+ // Skip member properties: <Foo.Bar /> should count Foo, not Bar
147
+ if (
148
+ parent &&
149
+ t.isJSXMemberExpression(parent) &&
150
+ parentKey === 'property'
151
+ ) {
152
+ return
153
+ }
154
+
155
+ // Intrinsic elements (lowercase) are not identifiers
156
+ const first = n.name[0]
157
+ if (first && first === first.toLowerCase()) {
158
+ return
159
+ }
160
+
161
+ ids.add(n.name)
162
+ return
163
+ }
164
+
165
+ for (const key of t.VISITOR_KEYS[n.type] || []) {
166
+ const child = (n as any)[key]
167
+ if (Array.isArray(child)) {
168
+ for (const c of child) {
169
+ if (c && typeof c.type === 'string') {
170
+ walk(c, n, parent, key)
171
+ }
172
+ }
173
+ } else if (child && typeof child.type === 'string') {
174
+ walk(child, n, parent, key)
175
+ }
176
+ }
177
+ })(node)
178
+
179
+ return ids
180
+ }
181
+
182
+ /**
183
+ * Build a map from binding name → declaration AST node for all
184
+ * locally-declared module-level bindings. Built once, O(1) lookup.
185
+ */
186
+ export function buildDeclarationMap(ast: t.File): Map<string, t.Node> {
187
+ const map = new Map<string, t.Node>()
188
+ for (const stmt of ast.program.body) {
189
+ const decl =
190
+ t.isExportNamedDeclaration(stmt) && stmt.declaration
191
+ ? stmt.declaration
192
+ : stmt
193
+
194
+ if (t.isVariableDeclaration(decl)) {
195
+ for (const declarator of decl.declarations) {
196
+ for (const name of collectIdentifiersFromPattern(declarator.id)) {
197
+ map.set(name, declarator)
198
+ }
199
+ }
200
+ } else if (t.isFunctionDeclaration(decl) && decl.id) {
201
+ map.set(decl.id.name, decl)
202
+ } else if (t.isClassDeclaration(decl) && decl.id) {
203
+ map.set(decl.id.name, decl)
204
+ }
205
+ }
206
+ return map
207
+ }
208
+
209
+ /**
210
+ * Build a dependency graph: for each local binding, the set of other local
211
+ * bindings its declaration references. Built once via simple node walking.
212
+ */
213
+ export function buildDependencyGraph(
214
+ declMap: Map<string, t.Node>,
215
+ localBindings: Set<string>,
216
+ ): Map<string, Set<string>> {
217
+ const graph = new Map<string, Set<string>>()
218
+ for (const [name, declNode] of declMap) {
219
+ if (!localBindings.has(name)) continue
220
+ const allIds = collectIdentifiersFromNode(declNode)
221
+ const deps = new Set<string>()
222
+ for (const id of allIds) {
223
+ if (id !== name && localBindings.has(id)) deps.add(id)
224
+ }
225
+ graph.set(name, deps)
226
+ }
227
+ return graph
228
+ }
229
+
230
+ /**
231
+ * Computes module-level bindings that are shared between split and non-split
232
+ * route properties. These bindings need to be extracted into a shared virtual
233
+ * module to avoid double-initialization.
234
+ *
235
+ * A binding is "shared" if it is referenced by at least one split property
236
+ * AND at least one non-split property. Only locally-declared module-level
237
+ * bindings are candidates (not imports — bundlers dedupe those).
238
+ */
239
+ export function computeSharedBindings(opts: {
240
+ code: string
241
+ codeSplitGroupings: CodeSplitGroupings
242
+ }): Set<string> {
243
+ const ast = parseAst(opts)
244
+
245
+ // Early bailout: collect all module-level locally-declared binding names.
246
+ // This is a cheap loop over program.body (no traversal). If the file has
247
+ // no local bindings (aside from `Route`), nothing can be shared — skip
248
+ // the expensive babel.traverse entirely.
249
+ const localModuleLevelBindings = new Set<string>()
250
+ for (const node of ast.program.body) {
251
+ collectLocalBindingsFromStatement(node, localModuleLevelBindings)
252
+ }
253
+
254
+ // File-based routes always export a route config binding (usually `Route`).
255
+ // This must never be extracted into the shared module.
256
+ localModuleLevelBindings.delete('Route')
257
+
258
+ if (localModuleLevelBindings.size === 0) {
259
+ return new Set()
260
+ }
261
+
262
+ function findIndexForSplitNode(str: string) {
263
+ return opts.codeSplitGroupings.findIndex((group) =>
264
+ group.includes(str as any),
265
+ )
266
+ }
267
+
268
+ // Find the route options object — needs babel.traverse for scope resolution
269
+ let routeOptions: t.ObjectExpression | undefined
270
+
271
+ babel.traverse(ast, {
272
+ CallExpression(path) {
273
+ if (!t.isIdentifier(path.node.callee)) return
274
+ if (!splittableCreateRouteFns.includes(path.node.callee.name)) return
275
+
276
+ if (t.isCallExpression(path.parentPath.node)) {
277
+ const opts = resolveIdentifier(path, path.parentPath.node.arguments[0])
278
+ if (t.isObjectExpression(opts)) routeOptions = opts
279
+ } else if (t.isVariableDeclarator(path.parentPath.node)) {
280
+ const caller = resolveIdentifier(path, path.parentPath.node.init)
281
+ if (t.isCallExpression(caller)) {
282
+ const opts = resolveIdentifier(path, caller.arguments[0])
283
+ if (t.isObjectExpression(opts)) routeOptions = opts
284
+ }
285
+ }
286
+ },
287
+ })
288
+
289
+ if (!routeOptions) return new Set()
290
+
291
+ // Fast path: if fewer than 2 distinct groups are referenced by route options,
292
+ // nothing can be shared and we can skip the rest of the work.
293
+ const splitGroupsPresent = new Set<number>()
294
+ let hasNonSplit = false
295
+ for (const prop of routeOptions.properties) {
296
+ if (!t.isObjectProperty(prop) || !t.isIdentifier(prop.key)) continue
297
+ if (prop.key.name === 'codeSplitGroupings') continue
298
+ if (t.isIdentifier(prop.value) && prop.value.name === 'undefined') continue
299
+ const groupIndex = findIndexForSplitNode(prop.key.name) // -1 if non-split
300
+ if (groupIndex === -1) {
301
+ hasNonSplit = true
302
+ } else {
303
+ splitGroupsPresent.add(groupIndex)
304
+ }
305
+ }
306
+
307
+ if (!hasNonSplit && splitGroupsPresent.size < 2) return new Set()
308
+
309
+ // Build dependency graph up front — needed for transitive expansion per-property.
310
+ // This graph excludes `Route` (deleted above) so group attribution works correctly.
311
+ const declMap = buildDeclarationMap(ast)
312
+ const depGraph = buildDependencyGraph(declMap, localModuleLevelBindings)
313
+
314
+ // Build a second dependency graph that includes `Route` so we can detect
315
+ // bindings that transitively depend on it. Such bindings must NOT be
316
+ // extracted into the shared module because they would drag the Route
317
+ // singleton with them, duplicating it across modules.
318
+ const allLocalBindings = new Set(localModuleLevelBindings)
319
+ allLocalBindings.add('Route')
320
+ const fullDepGraph = buildDependencyGraph(declMap, allLocalBindings)
321
+
322
+ // For each route property, track which "group" it belongs to.
323
+ // Non-split properties get group index -1.
324
+ // Split properties get their codeSplitGroupings index (0, 1, ...).
325
+ // A binding is "shared" if it appears in 2+ distinct groups.
326
+ // We expand each property's refs transitively BEFORE comparing groups,
327
+ // so indirect refs (e.g., component: MyComp where MyComp uses `shared`)
328
+ // are correctly attributed.
329
+ const refsByGroup = new Map<string, Set<number>>()
330
+
331
+ for (const prop of routeOptions.properties) {
332
+ if (!t.isObjectProperty(prop) || !t.isIdentifier(prop.key)) continue
333
+ const key = prop.key.name
334
+
335
+ if (key === 'codeSplitGroupings') continue
336
+
337
+ const groupIndex = findIndexForSplitNode(key) // -1 if non-split
338
+
339
+ const directRefs = collectModuleLevelRefsFromNode(
340
+ prop.value,
341
+ localModuleLevelBindings,
342
+ )
343
+
344
+ // Expand transitively: if component references SharedComp which references
345
+ // `shared`, then `shared` is also attributed to component's group.
346
+ const allRefs = new Set(directRefs)
347
+ expandTransitively(allRefs, depGraph)
348
+
349
+ for (const ref of allRefs) {
350
+ let groups = refsByGroup.get(ref)
351
+ if (!groups) {
352
+ groups = new Set()
353
+ refsByGroup.set(ref, groups)
354
+ }
355
+ groups.add(groupIndex)
356
+ }
357
+ }
358
+
359
+ // Shared = bindings appearing in 2+ distinct groups
360
+ const shared = new Set<string>()
361
+ for (const [name, groups] of refsByGroup) {
362
+ if (groups.size >= 2) shared.add(name)
363
+ }
364
+
365
+ // Destructured declarators (e.g. `const { a, b } = fn()`) must be treated
366
+ // as a single initialization unit. Even if each binding is referenced by
367
+ // only one group, if *different* bindings from the same declarator are
368
+ // referenced by different groups, the declarator must be extracted to the
369
+ // shared module to avoid double initialization.
370
+ expandSharedDestructuredDeclarators(ast, refsByGroup, shared)
371
+
372
+ if (shared.size === 0) return shared
373
+
374
+ // If any binding from a destructured declaration is shared,
375
+ // all bindings from that declaration must be shared
376
+ expandDestructuredDeclarations(ast, shared)
377
+
378
+ // Remove shared bindings that transitively depend on `Route`.
379
+ // The Route singleton must stay in the reference file; extracting a
380
+ // binding that references it would duplicate Route in the shared module.
381
+ removeBindingsDependingOnRoute(shared, fullDepGraph)
382
+
383
+ return shared
384
+ }
385
+
386
+ /**
387
+ * If bindings from the same destructured declarator are referenced by
388
+ * different groups, mark all bindings from that declarator as shared.
389
+ */
390
+ export function expandSharedDestructuredDeclarators(
391
+ ast: t.File,
392
+ refsByGroup: Map<string, Set<number>>,
393
+ shared: Set<string>,
394
+ ) {
395
+ for (const stmt of ast.program.body) {
396
+ const decl =
397
+ t.isExportNamedDeclaration(stmt) && stmt.declaration
398
+ ? stmt.declaration
399
+ : stmt
400
+
401
+ if (!t.isVariableDeclaration(decl)) continue
402
+
403
+ for (const declarator of decl.declarations) {
404
+ if (!t.isObjectPattern(declarator.id) && !t.isArrayPattern(declarator.id))
405
+ continue
406
+
407
+ const names = collectIdentifiersFromPattern(declarator.id)
408
+
409
+ const usedGroups = new Set<number>()
410
+ for (const name of names) {
411
+ const groups = refsByGroup.get(name)
412
+ if (!groups) continue
413
+ for (const g of groups) usedGroups.add(g)
414
+ }
415
+
416
+ if (usedGroups.size >= 2) {
417
+ for (const name of names) {
418
+ shared.add(name)
419
+ }
420
+ }
421
+ }
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Collect locally-declared module-level binding names from a statement.
427
+ * Pure node inspection, no traversal.
428
+ */
429
+ export function collectLocalBindingsFromStatement(
430
+ node: t.Statement | t.ModuleDeclaration,
431
+ bindings: Set<string>,
432
+ ) {
433
+ const decl =
434
+ t.isExportNamedDeclaration(node) && node.declaration
435
+ ? node.declaration
436
+ : node
437
+
438
+ if (t.isVariableDeclaration(decl)) {
439
+ for (const declarator of decl.declarations) {
440
+ for (const name of collectIdentifiersFromPattern(declarator.id)) {
441
+ bindings.add(name)
442
+ }
443
+ }
444
+ } else if (t.isFunctionDeclaration(decl) && decl.id) {
445
+ bindings.add(decl.id.name)
446
+ } else if (t.isClassDeclaration(decl) && decl.id) {
447
+ bindings.add(decl.id.name)
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Collect direct module-level binding names referenced from a given AST node.
453
+ * Uses a simple recursive walk instead of babel.traverse.
454
+ */
455
+ export function collectModuleLevelRefsFromNode(
456
+ node: t.Node,
457
+ localModuleLevelBindings: Set<string>,
458
+ ): Set<string> {
459
+ const allIds = collectIdentifiersFromNode(node)
460
+ const refs = new Set<string>()
461
+ for (const name of allIds) {
462
+ if (localModuleLevelBindings.has(name)) refs.add(name)
463
+ }
464
+ return refs
465
+ }
466
+
467
+ /**
468
+ * Expand the shared set transitively using a prebuilt dependency graph.
469
+ * No AST traversals — pure graph BFS.
470
+ */
471
+ export function expandTransitively(
472
+ shared: Set<string>,
473
+ depGraph: Map<string, Set<string>>,
474
+ ) {
475
+ const queue = [...shared]
476
+ const visited = new Set<string>()
477
+
478
+ while (queue.length > 0) {
479
+ const name = queue.pop()!
480
+ if (visited.has(name)) continue
481
+ visited.add(name)
482
+
483
+ const deps = depGraph.get(name)
484
+ if (!deps) continue
485
+
486
+ for (const dep of deps) {
487
+ if (!shared.has(dep)) {
488
+ shared.add(dep)
489
+ queue.push(dep)
490
+ }
491
+ }
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Remove any bindings from `shared` that transitively depend on `Route`.
497
+ * The Route singleton must remain in the reference file; if a shared binding
498
+ * references it (directly or transitively), extracting that binding would
499
+ * duplicate Route in the shared module.
500
+ *
501
+ * Uses `depGraph` which must include `Route` as a node so the dependency
502
+ * chain is visible.
503
+ */
504
+ export function removeBindingsDependingOnRoute(
505
+ shared: Set<string>,
506
+ depGraph: Map<string, Set<string>>,
507
+ ) {
508
+ const reverseGraph = new Map<string, Set<string>>()
509
+ for (const [name, deps] of depGraph) {
510
+ for (const dep of deps) {
511
+ let parents = reverseGraph.get(dep)
512
+ if (!parents) {
513
+ parents = new Set<string>()
514
+ reverseGraph.set(dep, parents)
515
+ }
516
+ parents.add(name)
517
+ }
518
+ }
519
+
520
+ // Walk backwards from Route to find all bindings that can reach it.
521
+ const visited = new Set<string>()
522
+ const queue = ['Route']
523
+ while (queue.length > 0) {
524
+ const cur = queue.pop()!
525
+ if (visited.has(cur)) continue
526
+ visited.add(cur)
527
+
528
+ const parents = reverseGraph.get(cur)
529
+ if (!parents) continue
530
+ for (const parent of parents) {
531
+ if (!visited.has(parent)) queue.push(parent)
532
+ }
533
+ }
534
+
535
+ for (const name of [...shared]) {
536
+ if (visited.has(name)) {
537
+ shared.delete(name)
538
+ }
539
+ }
540
+ }
541
+
542
+ /**
543
+ * If any binding from a destructured declaration is shared,
544
+ * ensure all bindings from that same declaration are also shared.
545
+ * Pure node inspection of program.body, no traversal.
546
+ */
547
+ export function expandDestructuredDeclarations(
548
+ ast: t.File,
549
+ shared: Set<string>,
550
+ ) {
551
+ for (const stmt of ast.program.body) {
552
+ const decl =
553
+ t.isExportNamedDeclaration(stmt) && stmt.declaration
554
+ ? stmt.declaration
555
+ : stmt
556
+
557
+ if (!t.isVariableDeclaration(decl)) continue
558
+
559
+ for (const declarator of decl.declarations) {
560
+ if (!t.isObjectPattern(declarator.id) && !t.isArrayPattern(declarator.id))
561
+ continue
562
+
563
+ const names = collectIdentifiersFromPattern(declarator.id)
564
+ const hasShared = names.some((n) => shared.has(n))
565
+ if (hasShared) {
566
+ for (const n of names) {
567
+ shared.add(n)
568
+ }
569
+ }
570
+ }
571
+ }
572
+ }
573
+
574
+ /**
575
+ * Find which shared bindings are user-exported in the original source.
576
+ * These need to be re-exported from the shared module.
577
+ */
578
+ function findExportedSharedBindings(
579
+ ast: t.File,
580
+ sharedBindings: Set<string>,
581
+ ): Set<string> {
582
+ const exported = new Set<string>()
583
+ for (const stmt of ast.program.body) {
584
+ if (!t.isExportNamedDeclaration(stmt) || !stmt.declaration) continue
585
+
586
+ if (t.isVariableDeclaration(stmt.declaration)) {
587
+ for (const decl of stmt.declaration.declarations) {
588
+ for (const name of collectIdentifiersFromPattern(decl.id)) {
589
+ if (sharedBindings.has(name)) exported.add(name)
590
+ }
591
+ }
592
+ } else if (
593
+ t.isFunctionDeclaration(stmt.declaration) &&
594
+ stmt.declaration.id
595
+ ) {
596
+ if (sharedBindings.has(stmt.declaration.id.name))
597
+ exported.add(stmt.declaration.id.name)
598
+ } else if (t.isClassDeclaration(stmt.declaration) && stmt.declaration.id) {
599
+ if (sharedBindings.has(stmt.declaration.id.name))
600
+ exported.add(stmt.declaration.id.name)
601
+ }
602
+ }
603
+ return exported
604
+ }
605
+
606
+ /**
607
+ * Remove declarations of shared bindings from the AST.
608
+ * Handles both plain and exported declarations, including destructured patterns.
609
+ * Removes the entire statement if all bindings in it are shared.
610
+ */
611
+ function removeSharedDeclarations(ast: t.File, sharedBindings: Set<string>) {
612
+ ast.program.body = ast.program.body.filter((stmt) => {
613
+ const decl =
614
+ t.isExportNamedDeclaration(stmt) && stmt.declaration
615
+ ? stmt.declaration
616
+ : stmt
617
+
618
+ if (t.isVariableDeclaration(decl)) {
619
+ // Filter out declarators where all bound names are shared
620
+ decl.declarations = decl.declarations.filter((declarator) => {
621
+ const names = collectIdentifiersFromPattern(declarator.id)
622
+ return !names.every((n) => sharedBindings.has(n))
623
+ })
624
+ // If no declarators remain, remove the entire statement
625
+ if (decl.declarations.length === 0) return false
626
+ } else if (t.isFunctionDeclaration(decl) && decl.id) {
627
+ if (sharedBindings.has(decl.id.name)) return false
628
+ } else if (t.isClassDeclaration(decl) && decl.id) {
629
+ if (sharedBindings.has(decl.id.name)) return false
630
+ }
631
+
632
+ return true
633
+ })
634
+ }
635
+
107
636
  export function compileCodeSplitReferenceRoute(
108
637
  opts: ParseAstOptions & {
109
638
  codeSplitGroupings: CodeSplitGroupings
@@ -112,6 +641,7 @@ export function compileCodeSplitReferenceRoute(
112
641
  filename: string
113
642
  id: string
114
643
  addHmr?: boolean
644
+ sharedBindings?: Set<string>
115
645
  },
116
646
  ): GeneratorResult | null {
117
647
  const ast = parseAst(opts)
@@ -135,6 +665,7 @@ export function compileCodeSplitReferenceRoute(
135
665
 
136
666
  let modified = false as boolean
137
667
  let hmrAdded = false as boolean
668
+ let sharedExportedNames: Set<string> | undefined
138
669
  babel.traverse(ast, {
139
670
  Program: {
140
671
  enter(programPath) {
@@ -424,6 +955,56 @@ export function compileCodeSplitReferenceRoute(
424
955
  },
425
956
  })
426
957
  }
958
+
959
+ // Handle shared bindings inside the Program visitor so we have
960
+ // access to programPath for cheap refIdents registration.
961
+ if (opts.sharedBindings && opts.sharedBindings.size > 0) {
962
+ sharedExportedNames = findExportedSharedBindings(
963
+ ast,
964
+ opts.sharedBindings,
965
+ )
966
+ removeSharedDeclarations(ast, opts.sharedBindings)
967
+
968
+ const sharedModuleUrl = addSharedSearchParamToFilename(opts.filename)
969
+
970
+ const sharedImportSpecifiers = [...opts.sharedBindings].map((name) =>
971
+ t.importSpecifier(t.identifier(name), t.identifier(name)),
972
+ )
973
+ const [sharedImportPath] = programPath.unshiftContainer(
974
+ 'body',
975
+ t.importDeclaration(
976
+ sharedImportSpecifiers,
977
+ t.stringLiteral(sharedModuleUrl),
978
+ ),
979
+ )
980
+
981
+ // Register import specifier locals in refIdents so DCE can remove unused ones
982
+ sharedImportPath.traverse({
983
+ Identifier(identPath) {
984
+ if (
985
+ identPath.parentPath.isImportSpecifier() &&
986
+ identPath.key === 'local'
987
+ ) {
988
+ refIdents.add(identPath)
989
+ }
990
+ },
991
+ })
992
+
993
+ // Re-export user-exported shared bindings from the shared module
994
+ if (sharedExportedNames.size > 0) {
995
+ const reExportSpecifiers = [...sharedExportedNames].map((name) =>
996
+ t.exportSpecifier(t.identifier(name), t.identifier(name)),
997
+ )
998
+ programPath.pushContainer(
999
+ 'body',
1000
+ t.exportNamedDeclaration(
1001
+ null,
1002
+ reExportSpecifiers,
1003
+ t.stringLiteral(sharedModuleUrl),
1004
+ ),
1005
+ )
1006
+ }
1007
+ }
427
1008
  },
428
1009
  },
429
1010
  })
@@ -465,11 +1046,18 @@ export function compileCodeSplitVirtualRoute(
465
1046
  opts: ParseAstOptions & {
466
1047
  splitTargets: Array<SplitRouteIdentNodes>
467
1048
  filename: string
1049
+ sharedBindings?: Set<string>
468
1050
  },
469
1051
  ): GeneratorResult {
470
1052
  const ast = parseAst(opts)
471
1053
  const refIdents = findReferencedIdentifiers(ast)
472
1054
 
1055
+ // Remove shared declarations BEFORE babel.traverse so the scope never sees
1056
+ // conflicting bindings (avoids checkBlockScopedCollisions crash in DCE)
1057
+ if (opts.sharedBindings && opts.sharedBindings.size > 0) {
1058
+ removeSharedDeclarations(ast, opts.sharedBindings)
1059
+ }
1060
+
473
1061
  const intendedSplitNodes = new Set(opts.splitTargets)
474
1062
 
475
1063
  const knownExportedIdents = new Set<string>()
@@ -814,12 +1402,188 @@ export function compileCodeSplitVirtualRoute(
814
1402
  }
815
1403
  },
816
1404
  })
1405
+
1406
+ // Add shared bindings import, registering specifiers in refIdents
1407
+ // so DCE can remove unused ones (same pattern as import replacements above).
1408
+ if (opts.sharedBindings && opts.sharedBindings.size > 0) {
1409
+ const sharedImportSpecifiers = [...opts.sharedBindings].map((name) =>
1410
+ t.importSpecifier(t.identifier(name), t.identifier(name)),
1411
+ )
1412
+ const sharedModuleUrl = addSharedSearchParamToFilename(
1413
+ removeSplitSearchParamFromFilename(opts.filename),
1414
+ )
1415
+ const [sharedImportPath] = programPath.unshiftContainer(
1416
+ 'body',
1417
+ t.importDeclaration(
1418
+ sharedImportSpecifiers,
1419
+ t.stringLiteral(sharedModuleUrl),
1420
+ ),
1421
+ )
1422
+
1423
+ sharedImportPath.traverse({
1424
+ Identifier(identPath) {
1425
+ if (
1426
+ identPath.parentPath.isImportSpecifier() &&
1427
+ identPath.key === 'local'
1428
+ ) {
1429
+ refIdents.add(identPath)
1430
+ }
1431
+ },
1432
+ })
1433
+ }
817
1434
  },
818
1435
  },
819
1436
  })
820
1437
 
821
1438
  deadCodeElimination(ast, refIdents)
822
1439
 
1440
+ // Strip top-level expression statements that reference no locally-bound names.
1441
+ // DCE only removes unused declarations; bare side-effect statements like
1442
+ // `console.log(...)` survive even when the virtual file has no exports.
1443
+ {
1444
+ const locallyBound = new Set<string>()
1445
+ for (const stmt of ast.program.body) {
1446
+ collectLocalBindingsFromStatement(stmt, locallyBound)
1447
+ }
1448
+ ast.program.body = ast.program.body.filter((stmt) => {
1449
+ if (!t.isExpressionStatement(stmt)) return true
1450
+ const refs = collectIdentifiersFromNode(stmt)
1451
+ // Keep if it references at least one locally-bound identifier
1452
+ return [...refs].some((name) => locallyBound.has(name))
1453
+ })
1454
+ }
1455
+
1456
+ // If the body is empty after DCE, strip directive prologues too.
1457
+ // A file containing only `'use client'` with no real code is useless.
1458
+ if (ast.program.body.length === 0) {
1459
+ ast.program.directives = []
1460
+ }
1461
+
1462
+ return generateFromAst(ast, {
1463
+ sourceMaps: true,
1464
+ sourceFileName: opts.filename,
1465
+ filename: opts.filename,
1466
+ })
1467
+ }
1468
+
1469
+ /**
1470
+ * Compile the shared virtual module (`?tsr-shared=1`).
1471
+ * Keeps only shared binding declarations, their transitive dependencies,
1472
+ * and imports they need. Exports all shared bindings.
1473
+ */
1474
+ export function compileCodeSplitSharedRoute(
1475
+ opts: ParseAstOptions & {
1476
+ sharedBindings: Set<string>
1477
+ filename: string
1478
+ },
1479
+ ): GeneratorResult {
1480
+ const ast = parseAst(opts)
1481
+ const refIdents = findReferencedIdentifiers(ast)
1482
+
1483
+ // Collect all names that need to stay: shared bindings + their transitive deps
1484
+ const localBindings = new Set<string>()
1485
+ for (const node of ast.program.body) {
1486
+ collectLocalBindingsFromStatement(node, localBindings)
1487
+ }
1488
+
1489
+ // Route must never be extracted into the shared module.
1490
+ // Excluding it from the dep graph prevents expandTransitively from
1491
+ // pulling it in as a transitive dependency of a shared binding.
1492
+ localBindings.delete('Route')
1493
+
1494
+ const declMap = buildDeclarationMap(ast)
1495
+ const depGraph = buildDependencyGraph(declMap, localBindings)
1496
+
1497
+ // Start with shared bindings and expand transitively
1498
+ const keepBindings = new Set(opts.sharedBindings)
1499
+ keepBindings.delete('Route')
1500
+ expandTransitively(keepBindings, depGraph)
1501
+
1502
+ // Remove all statements except:
1503
+ // - Import declarations (needed for deps; DCE will clean unused ones)
1504
+ // - Declarations of bindings in keepBindings
1505
+ ast.program.body = ast.program.body.filter((stmt) => {
1506
+ // Always keep imports — DCE will remove unused ones
1507
+ if (t.isImportDeclaration(stmt)) return true
1508
+
1509
+ const decl =
1510
+ t.isExportNamedDeclaration(stmt) && stmt.declaration
1511
+ ? stmt.declaration
1512
+ : stmt
1513
+
1514
+ if (t.isVariableDeclaration(decl)) {
1515
+ // Keep declarators where at least one binding is in keepBindings
1516
+ decl.declarations = decl.declarations.filter((declarator) => {
1517
+ const names = collectIdentifiersFromPattern(declarator.id)
1518
+ return names.some((n) => keepBindings.has(n))
1519
+ })
1520
+ if (decl.declarations.length === 0) return false
1521
+
1522
+ // Strip the `export` wrapper — shared module controls its own exports
1523
+ if (t.isExportNamedDeclaration(stmt) && stmt.declaration) {
1524
+ return true // keep for now, we'll convert below
1525
+ }
1526
+ return true
1527
+ } else if (t.isFunctionDeclaration(decl) && decl.id) {
1528
+ return keepBindings.has(decl.id.name)
1529
+ } else if (t.isClassDeclaration(decl) && decl.id) {
1530
+ return keepBindings.has(decl.id.name)
1531
+ }
1532
+
1533
+ // Remove everything else (expression statements, other exports, etc.)
1534
+ return false
1535
+ })
1536
+
1537
+ // Convert `export const/function/class` to plain declarations
1538
+ // (we'll add our own export statement at the end)
1539
+ ast.program.body = ast.program.body.map((stmt) => {
1540
+ if (t.isExportNamedDeclaration(stmt) && stmt.declaration) {
1541
+ return stmt.declaration
1542
+ }
1543
+ return stmt
1544
+ })
1545
+
1546
+ // Export all shared bindings (sorted for deterministic output)
1547
+ const exportNames = [...opts.sharedBindings].sort((a, b) =>
1548
+ a.localeCompare(b),
1549
+ )
1550
+ const exportSpecifiers = exportNames.map((name) =>
1551
+ t.exportSpecifier(t.identifier(name), t.identifier(name)),
1552
+ )
1553
+ if (exportSpecifiers.length > 0) {
1554
+ const exportDecl = t.exportNamedDeclaration(null, exportSpecifiers)
1555
+ ast.program.body.push(exportDecl)
1556
+
1557
+ // Register export specifier locals in refIdents so DCE doesn't treat
1558
+ // the exported bindings as unreferenced.
1559
+ babel.traverse(ast, {
1560
+ Program(programPath) {
1561
+ const bodyPaths = programPath.get('body')
1562
+ const last = bodyPaths[bodyPaths.length - 1]
1563
+ if (last && last.isExportNamedDeclaration()) {
1564
+ last.traverse({
1565
+ Identifier(identPath) {
1566
+ if (
1567
+ identPath.parentPath.isExportSpecifier() &&
1568
+ identPath.key === 'local'
1569
+ ) {
1570
+ refIdents.add(identPath)
1571
+ }
1572
+ },
1573
+ })
1574
+ }
1575
+ programPath.stop()
1576
+ },
1577
+ })
1578
+ }
1579
+
1580
+ deadCodeElimination(ast, refIdents)
1581
+
1582
+ // If the body is empty after DCE, strip directive prologues too.
1583
+ if (ast.program.body.length === 0) {
1584
+ ast.program.directives = []
1585
+ }
1586
+
823
1587
  return generateFromAst(ast, {
824
1588
  sourceMaps: true,
825
1589
  sourceFileName: opts.filename,