From db8069ef28195a32e35f7efd9f882cec719c1429 Mon Sep 17 00:00:00 2001
From: wookhyung <qpflapffhs76@gmail.com>
Date: Wed, 16 Jul 2025 22:25:39 +0900
Subject: [PATCH 1/5] feat(router-generator): implement AST-based export
 detection for routes

---
 packages/router-generator/src/generator.ts    | 91 +++++++++++++++++--
 .../generator/root-export-separate/_root.tsx  |  7 ++
 .../generator/root-export-separate/index.tsx  |  5 +
 .../routeTree.snapshot.ts                     | 59 ++++++++++++
 4 files changed, 153 insertions(+), 9 deletions(-)
 create mode 100644 packages/router-generator/tests/generator/root-export-separate/_root.tsx
 create mode 100644 packages/router-generator/tests/generator/root-export-separate/index.tsx
 create mode 100644 packages/router-generator/tests/generator/root-export-separate/routeTree.snapshot.ts

diff --git a/packages/router-generator/src/generator.ts b/packages/router-generator/src/generator.ts
index 6d868af1ab..d290e38001 100644
--- a/packages/router-generator/src/generator.ts
+++ b/packages/router-generator/src/generator.ts
@@ -3,6 +3,8 @@ import * as fsp from 'node:fs/promises'
 import { mkdtempSync } from 'node:fs'
 import crypto from 'node:crypto'
 import { deepEqual, rootRouteId } from '@tanstack/router-core'
+import { parseAst } from '@tanstack/router-utils'
+import { parse } from 'recast'
 import { logging } from './logger'
 import {
   isVirtualConfigFile,
@@ -217,6 +219,79 @@ export class Generator {
     })
   }
 
+  private detectExportsUsingAST(
+    fileContent: string,
+    exportNames: Array<string>,
+  ): Array<string> {
+    try {
+      const ast = parse(fileContent, {
+        sourceFileName: 'detect-exports.ts',
+        parser: {
+          parse(code: string) {
+            return parseAst({
+              code,
+              tokens: true,
+            })
+          },
+        },
+      })
+
+      const program = ast.program
+      const foundExports: Array<string> = []
+
+      for (const n of program.body) {
+        if (n.type === 'ExportNamedDeclaration') {
+          if (n.declaration?.type === 'VariableDeclaration') {
+            const decl = n.declaration.declarations[0]
+            if (
+              decl &&
+              decl.type === 'VariableDeclarator' &&
+              decl.id.type === 'Identifier' &&
+              exportNames.includes(decl.id.name)
+            ) {
+              foundExports.push(decl.id.name)
+            }
+          } else if (n.declaration === null && n.specifiers) {
+            for (const spec of n.specifiers) {
+              if (
+                typeof spec.exported.name === 'string' &&
+                exportNames.includes(spec.exported.name)
+              ) {
+                const variableName = spec.local?.name || spec.exported.name
+                for (const decl of program.body) {
+                  if (
+                    decl.type === 'VariableDeclaration' &&
+                    decl.declarations[0]
+                  ) {
+                    const variable = decl.declarations[0]
+                    if (
+                      variable.type === 'VariableDeclarator' &&
+                      variable.id.type === 'Identifier' &&
+                      variable.id.name === variableName
+                    ) {
+                      foundExports.push(spec.exported.name)
+                      break
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+
+      return foundExports
+    } catch (error) {
+      this.logger.error(
+        'Failed to parse AST for export detection, falling back to string matching',
+        error,
+      )
+      return exportNames.filter((exportName) =>
+        fileContent.includes(`export const ${exportName}`),
+      )
+    }
+  }
+
   private getRoutesDirectoryPath() {
     return path.isAbsolute(this.config.routesDirectory)
       ? this.config.routesDirectory
@@ -1207,15 +1282,13 @@ ${acc.routeTree.map((child) => `${child.variableName}${exportName}: typeof ${get
       updatedCacheEntry.mtimeMs = stats.mtimeMs
     }
 
-    const rootRouteExports: Array<string> = []
-    for (const plugin of this.pluginsWithTransform) {
-      const exportName = plugin.transformPlugin.exportName
-      // TODO we need to parse instead of just string match
-      // otherwise a commented out export will still be detected
-      if (rootNodeFile.fileContent.includes(`export const ${exportName}`)) {
-        rootRouteExports.push(exportName)
-      }
-    }
+    const exportNames = this.pluginsWithTransform.map(
+      (plugin) => plugin.transformPlugin.exportName,
+    )
+    const rootRouteExports = this.detectExportsUsingAST(
+      rootNodeFile.fileContent,
+      exportNames,
+    )
 
     updatedCacheEntry.exports = rootRouteExports
     node.exports = rootRouteExports
diff --git a/packages/router-generator/tests/generator/root-export-separate/_root.tsx b/packages/router-generator/tests/generator/root-export-separate/_root.tsx
new file mode 100644
index 0000000000..014506f7e7
--- /dev/null
+++ b/packages/router-generator/tests/generator/root-export-separate/_root.tsx
@@ -0,0 +1,7 @@
+import { createRootRoute } from '@tanstack/react-router'
+
+const Route = createRootRoute({
+  component: () => <div>Root Layout</div>,
+})
+
+export { Route }
diff --git a/packages/router-generator/tests/generator/root-export-separate/index.tsx b/packages/router-generator/tests/generator/root-export-separate/index.tsx
new file mode 100644
index 0000000000..727d16639b
--- /dev/null
+++ b/packages/router-generator/tests/generator/root-export-separate/index.tsx
@@ -0,0 +1,5 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/')({
+  component: () => <div>Hello World</div>,
+})
diff --git a/packages/router-generator/tests/generator/root-export-separate/routeTree.snapshot.ts b/packages/router-generator/tests/generator/root-export-separate/routeTree.snapshot.ts
new file mode 100644
index 0000000000..d204c269b3
--- /dev/null
+++ b/packages/router-generator/tests/generator/root-export-separate/routeTree.snapshot.ts
@@ -0,0 +1,59 @@
+/* eslint-disable */
+
+// @ts-nocheck
+
+// noinspection JSUnusedGlobalSymbols
+
+// This file was automatically generated by TanStack Router.
+// You should NOT make any changes in this file as it will be overwritten.
+// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
+
+import { Route as rootRouteImport } from './routes/__root'
+import { Route as IndexRouteImport } from './routes/index'
+
+const IndexRoute = IndexRouteImport.update({
+  id: '/',
+  path: '/',
+  getParentRoute: () => rootRouteImport,
+} as any)
+
+export interface FileRoutesByFullPath {
+  '/': typeof IndexRoute
+}
+export interface FileRoutesByTo {
+  '/': typeof IndexRoute
+}
+export interface FileRoutesById {
+  __root__: typeof rootRouteImport
+  '/': typeof IndexRoute
+}
+export interface FileRouteTypes {
+  fileRoutesByFullPath: FileRoutesByFullPath
+  fullPaths: '/'
+  fileRoutesByTo: FileRoutesByTo
+  to: '/'
+  id: '__root__' | '/'
+  fileRoutesById: FileRoutesById
+}
+export interface RootRouteChildren {
+  IndexRoute: typeof IndexRoute
+}
+
+declare module '@tanstack/react-router' {
+  interface FileRoutesByPath {
+    '/': {
+      id: '/'
+      path: '/'
+      fullPath: '/'
+      preLoaderRoute: typeof IndexRouteImport
+      parentRoute: typeof rootRouteImport
+    }
+  }
+}
+
+const rootRouteChildren: RootRouteChildren = {
+  IndexRoute: IndexRoute,
+}
+export const routeTree = rootRouteImport
+  ._addFileChildren(rootRouteChildren)
+  ._addFileTypes<FileRouteTypes>()

From 5b2fae41483bae52a22c9afc7183ff21f54de23b Mon Sep 17 00:00:00 2001
From: wookhyung <qpflapffhs76@gmail.com>
Date: Wed, 16 Jul 2025 22:31:22 +0900
Subject: [PATCH 2/5] feat(router-generator): add root and file routes

---
 .../root-export-separate/{_root.tsx => routes/__root.tsx}         | 0
 .../tests/generator/root-export-separate/{ => routes}/index.tsx   | 0
 2 files changed, 0 insertions(+), 0 deletions(-)
 rename packages/router-generator/tests/generator/root-export-separate/{_root.tsx => routes/__root.tsx} (100%)
 rename packages/router-generator/tests/generator/root-export-separate/{ => routes}/index.tsx (100%)

diff --git a/packages/router-generator/tests/generator/root-export-separate/_root.tsx b/packages/router-generator/tests/generator/root-export-separate/routes/__root.tsx
similarity index 100%
rename from packages/router-generator/tests/generator/root-export-separate/_root.tsx
rename to packages/router-generator/tests/generator/root-export-separate/routes/__root.tsx
diff --git a/packages/router-generator/tests/generator/root-export-separate/index.tsx b/packages/router-generator/tests/generator/root-export-separate/routes/index.tsx
similarity index 100%
rename from packages/router-generator/tests/generator/root-export-separate/index.tsx
rename to packages/router-generator/tests/generator/root-export-separate/routes/index.tsx

From 5dd9412fb95e3c1314edf2d77acedfcee1827617 Mon Sep 17 00:00:00 2001
From: wookhyung <qpflapffhs76@gmail.com>
Date: Tue, 22 Jul 2025 17:49:33 +0900
Subject: [PATCH 3/5] refactor(router-generator): integrate AST-based export
 detection logic

---
 packages/router-generator/src/generator.ts    |  78 +-------------
 .../src/transform/transform.ts                |  53 ++-------
 packages/router-generator/src/utils.ts        | 102 ++++++++++++++++++
 3 files changed, 114 insertions(+), 119 deletions(-)

diff --git a/packages/router-generator/src/generator.ts b/packages/router-generator/src/generator.ts
index d290e38001..9b1f8f2524 100644
--- a/packages/router-generator/src/generator.ts
+++ b/packages/router-generator/src/generator.ts
@@ -3,8 +3,6 @@ import * as fsp from 'node:fs/promises'
 import { mkdtempSync } from 'node:fs'
 import crypto from 'node:crypto'
 import { deepEqual, rootRouteId } from '@tanstack/router-core'
-import { parseAst } from '@tanstack/router-utils'
-import { parse } from 'recast'
 import { logging } from './logger'
 import {
   isVirtualConfigFile,
@@ -20,6 +18,7 @@ import {
   createRouteNodesByFullPath,
   createRouteNodesById,
   createRouteNodesByTo,
+  detectExportsFromSource,
   determineNodePath,
   findParent,
   format,
@@ -219,79 +218,6 @@ export class Generator {
     })
   }
 
-  private detectExportsUsingAST(
-    fileContent: string,
-    exportNames: Array<string>,
-  ): Array<string> {
-    try {
-      const ast = parse(fileContent, {
-        sourceFileName: 'detect-exports.ts',
-        parser: {
-          parse(code: string) {
-            return parseAst({
-              code,
-              tokens: true,
-            })
-          },
-        },
-      })
-
-      const program = ast.program
-      const foundExports: Array<string> = []
-
-      for (const n of program.body) {
-        if (n.type === 'ExportNamedDeclaration') {
-          if (n.declaration?.type === 'VariableDeclaration') {
-            const decl = n.declaration.declarations[0]
-            if (
-              decl &&
-              decl.type === 'VariableDeclarator' &&
-              decl.id.type === 'Identifier' &&
-              exportNames.includes(decl.id.name)
-            ) {
-              foundExports.push(decl.id.name)
-            }
-          } else if (n.declaration === null && n.specifiers) {
-            for (const spec of n.specifiers) {
-              if (
-                typeof spec.exported.name === 'string' &&
-                exportNames.includes(spec.exported.name)
-              ) {
-                const variableName = spec.local?.name || spec.exported.name
-                for (const decl of program.body) {
-                  if (
-                    decl.type === 'VariableDeclaration' &&
-                    decl.declarations[0]
-                  ) {
-                    const variable = decl.declarations[0]
-                    if (
-                      variable.type === 'VariableDeclarator' &&
-                      variable.id.type === 'Identifier' &&
-                      variable.id.name === variableName
-                    ) {
-                      foundExports.push(spec.exported.name)
-                      break
-                    }
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-
-      return foundExports
-    } catch (error) {
-      this.logger.error(
-        'Failed to parse AST for export detection, falling back to string matching',
-        error,
-      )
-      return exportNames.filter((exportName) =>
-        fileContent.includes(`export const ${exportName}`),
-      )
-    }
-  }
-
   private getRoutesDirectoryPath() {
     return path.isAbsolute(this.config.routesDirectory)
       ? this.config.routesDirectory
@@ -1285,7 +1211,7 @@ ${acc.routeTree.map((child) => `${child.variableName}${exportName}: typeof ${get
     const exportNames = this.pluginsWithTransform.map(
       (plugin) => plugin.transformPlugin.exportName,
     )
-    const rootRouteExports = this.detectExportsUsingAST(
+    const rootRouteExports = detectExportsFromSource(
       rootNodeFile.fileContent,
       exportNames,
     )
diff --git a/packages/router-generator/src/transform/transform.ts b/packages/router-generator/src/transform/transform.ts
index 162acfb281..77ec458d35 100644
--- a/packages/router-generator/src/transform/transform.ts
+++ b/packages/router-generator/src/transform/transform.ts
@@ -1,7 +1,7 @@
 import { parseAst } from '@tanstack/router-utils'
 import { parse, print, types, visit } from 'recast'
 import { SourceMapConsumer } from 'source-map'
-import { mergeImportDeclarations } from '../utils'
+import { matchExportInNode, mergeImportDeclarations } from '../utils'
 import type { ImportDeclaration } from '../types'
 import type { RawSourceMap } from 'source-map'
 import type {
@@ -76,49 +76,16 @@ export async function transform({
   }
 
   const program: types.namedTypes.Program = ast.program
-  // first pass: find registered exports
+
+  // Use the shared matching logic with minimal changes to existing structure
+  const exportNames = Array.from(registeredExports.keys())
   for (const n of program.body) {
-    if (registeredExports.size > 0 && n.type === 'ExportNamedDeclaration') {
-      // direct export of a variable declaration, e.g. `export const Route = createFileRoute('/path')`
-      if (n.declaration?.type === 'VariableDeclaration') {
-        const decl = n.declaration.declarations[0]
-        if (
-          decl &&
-          decl.type === 'VariableDeclarator' &&
-          decl.id.type === 'Identifier'
-        ) {
-          const plugin = registeredExports.get(decl.id.name)
-          if (plugin) {
-            onExportFound(decl, decl.id.name, plugin)
-          }
-        }
-      }
-      // this is an export without a declaration, e.g. `export { Route }`
-      else if (n.declaration === null && n.specifiers) {
-        for (const spec of n.specifiers) {
-          if (typeof spec.exported.name === 'string') {
-            const plugin = registeredExports.get(spec.exported.name)
-            if (plugin) {
-              const variableName = spec.local?.name || spec.exported.name
-              // find the matching variable declaration by iterating over the top-level declarations
-              for (const decl of program.body) {
-                if (
-                  decl.type === 'VariableDeclaration' &&
-                  decl.declarations[0]
-                ) {
-                  const variable = decl.declarations[0]
-                  if (
-                    variable.type === 'VariableDeclarator' &&
-                    variable.id.type === 'Identifier' &&
-                    variable.id.name === variableName
-                  ) {
-                    onExportFound(variable, spec.exported.name, plugin)
-                    break
-                  }
-                }
-              }
-            }
-          }
+    if (registeredExports.size > 0) {
+      const match = matchExportInNode(n, exportNames, program)
+      if (match) {
+        const plugin = registeredExports.get(match.exportName)
+        if (plugin) {
+          onExportFound(match.decl, match.exportName, plugin)
         }
       }
     }
diff --git a/packages/router-generator/src/utils.ts b/packages/router-generator/src/utils.ts
index b8c45d61f0..65d45eed00 100644
--- a/packages/router-generator/src/utils.ts
+++ b/packages/router-generator/src/utils.ts
@@ -1,7 +1,10 @@
 import * as fsp from 'node:fs/promises'
 import path from 'node:path'
 import * as prettier from 'prettier'
+import { parseAst } from '@tanstack/router-utils'
+import { parse } from 'recast'
 import { rootPathId } from './filesystem/physical/rootPathId'
+import type { types } from 'recast'
 import type { Config } from './config'
 import type { ImportDeclaration, RouteNode } from './types'
 
@@ -631,3 +634,102 @@ export function buildFileRoutesByPathInterface(opts: {
   }
 }`
 }
+
+/**
+ * Helper function to find a variable declaration by name in the program body
+ */
+function findVariableDeclaration(
+  program: types.namedTypes.Program,
+  variableName: string,
+): types.namedTypes.VariableDeclarator | null {
+  for (const decl of program.body) {
+    if (decl.type === 'VariableDeclaration' && decl.declarations[0]) {
+      const variable = decl.declarations[0]
+      if (
+        variable.type === 'VariableDeclarator' &&
+        variable.id.type === 'Identifier' &&
+        variable.id.name === variableName
+      ) {
+        return variable
+      }
+    }
+  }
+  return null
+}
+
+/**
+ * Core matching logic: checks if a single AST node contains an export we're looking for.
+ * This is the shared logic used by both transform.ts and generator.ts.
+ */
+export function matchExportInNode(
+  node: any,
+  exportNames: Array<string>,
+  program: types.namedTypes.Program,
+): { exportName: string; decl: types.namedTypes.VariableDeclarator } | null {
+  if (node.type === 'ExportNamedDeclaration') {
+    // direct export of a variable declaration, e.g. `export const Route = createFileRoute('/path')`
+    if (node.declaration?.type === 'VariableDeclaration') {
+      const decl = node.declaration.declarations[0]
+      if (
+        decl &&
+        decl.type === 'VariableDeclarator' &&
+        decl.id.type === 'Identifier' &&
+        exportNames.includes(decl.id.name)
+      ) {
+        return { exportName: decl.id.name, decl }
+      }
+    }
+    // this is an export without a declaration, e.g. `export { Route }`
+    else if (node.declaration === null && node.specifiers) {
+      for (const spec of node.specifiers) {
+        if (
+          typeof spec.exported.name === 'string' &&
+          exportNames.includes(spec.exported.name)
+        ) {
+          const variableName = spec.local?.name || spec.exported.name
+          const decl = findVariableDeclaration(program, variableName)
+          if (decl) {
+            return { exportName: spec.exported.name, decl }
+          }
+        }
+      }
+    }
+  }
+  return null
+}
+
+/**
+ * Simple function for generator.ts: parses source and returns export names.
+ */
+export function detectExportsFromSource(
+  source: string,
+  exportNames: Array<string>,
+): Array<string> {
+  try {
+    const ast = parse(source, {
+      sourceFileName: 'detect-exports.ts',
+      parser: {
+        parse(code: string) {
+          return parseAst({
+            code,
+            tokens: true,
+          })
+        },
+      },
+    })
+
+    const foundExports: Array<string> = []
+    for (const node of ast.program.body) {
+      const match = matchExportInNode(node, exportNames, ast.program)
+      if (match) {
+        foundExports.push(match.exportName)
+      }
+    }
+    return foundExports
+  } catch (error) {
+    // Fallback to string matching if AST parsing fails
+    return exportNames.filter((exportName) =>
+      source.includes(`export const ${exportName}`),
+    )
+  }
+}

From 82330e584eaed0f010b30bc86780cc354c6137cb Mon Sep 17 00:00:00 2001
From: wookhyung <qpflapffhs76@gmail.com>
Date: Tue, 22 Jul 2025 17:50:41 +0900
Subject: [PATCH 4/5] refactor(router-generator): remove unused comments

---
 packages/router-generator/src/utils.ts | 11 -----------
 1 file changed, 11 deletions(-)

diff --git a/packages/router-generator/src/utils.ts b/packages/router-generator/src/utils.ts
index 65d45eed00..ea408ec908 100644
--- a/packages/router-generator/src/utils.ts
+++ b/packages/router-generator/src/utils.ts
@@ -635,9 +635,6 @@ export function buildFileRoutesByPathInterface(opts: {
 }`
 }
 
-/**
- * Helper function to find a variable declaration by name in the program body
- */
 function findVariableDeclaration(
   program: types.namedTypes.Program,
   variableName: string,
@@ -657,10 +654,6 @@ function findVariableDeclaration(
   return null
 }
 
-/**
- * Core matching logic: checks if a single AST node contains an export we're looking for.
- * This is the shared logic used by both transform.ts and generator.ts.
- */
 export function matchExportInNode(
   node: any,
   exportNames: Array<string>,
@@ -698,9 +691,6 @@ export function matchExportInNode(
   return null
 }
 
-/**
- * Simple function for generator.ts: parses source and returns export names.
- */
 export function detectExportsFromSource(
   source: string,
   exportNames: Array<string>,
@@ -727,7 +717,6 @@ export function detectExportsFromSource(
     }
     return foundExports
   } catch (error) {
-    // Fallback to string matching if AST parsing fails
     return exportNames.filter((exportName) =>
       source.includes(`export const ${exportName}`),
     )

From 13a520d66dc5553c4ef29581180e7ae027bebd9e Mon Sep 17 00:00:00 2001
From: wookhyung <qpflapffhs76@gmail.com>
Date: Tue, 22 Jul 2025 21:27:35 +0900
Subject: [PATCH 5/5] refactor(router-generator): remove redundant comment

---
 packages/router-generator/src/transform/transform.ts | 1 -
 1 file changed, 1 deletion(-)

diff --git a/packages/router-generator/src/transform/transform.ts b/packages/router-generator/src/transform/transform.ts
index 77ec458d35..574c798c5e 100644
--- a/packages/router-generator/src/transform/transform.ts
+++ b/packages/router-generator/src/transform/transform.ts
@@ -77,7 +77,6 @@ export async function transform({
 
   const program: types.namedTypes.Program = ast.program
 
-  // Use the shared matching logic with minimal changes to existing structure
   const exportNames = Array.from(registeredExports.keys())
   for (const n of program.body) {
     if (registeredExports.size > 0) {
