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
6 changes: 6 additions & 0 deletions .changeset/shy-exports-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/router-utils': patch
'@tanstack/router-plugin': patch
---

Keep route exports intact under automatic code splitting: exported classes and `export { name as alias }` specifiers are retained like other exported route options, and the overload signatures of a function moved into the shared module no longer stay behind to shadow its import. Re-exports of the same name from different sources, such as `export { wrap as allowed } from './server'` next to `export { wrap as denied } from './client'`, now resolve to their own sources in the Start compiler.
133 changes: 124 additions & 9 deletions packages/router-plugin/src/core/code-splitter/compilers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,9 @@ function findExportedSharedBindings(
* Remove declarations of shared bindings from the AST.
* Handles both plain and exported declarations, including destructured patterns.
* Removes the entire statement if all bindings in it are shared.
* TypeScript overload signatures of a shared function are removed along with
* its implementation: left behind, they read as a local declaration of the
* same name and shadow the import from the shared module.
*/
function removeSharedDeclarations(ast: t.File, sharedBindings: Set<string>) {
ast.program.body = ast.program.body.filter((stmt) => {
Expand All @@ -351,7 +354,10 @@ function removeSharedDeclarations(ast: t.File, sharedBindings: Set<string>) {
})
// If no declarators remain, remove the entire statement
if (decl.declarations.length === 0) return false
} else if (t.isFunctionDeclaration(decl) && decl.id) {
} else if (
(t.isFunctionDeclaration(decl) || t.isTSDeclareFunction(decl)) &&
decl.id
) {
if (sharedBindings.has(decl.id.name)) return false
} else if (t.isClassDeclaration(decl) && decl.id) {
if (sharedBindings.has(decl.id.name)) return false
Expand Down Expand Up @@ -1033,6 +1039,18 @@ export function compileCodeSplitVirtualRoute(
)
}

splitMeta.shouldRemoveNode = false
splitMeta.localExporterIdent = splitNode.id.name
} else if (t.isClassDeclaration(splitNode)) {
// declared as `class Layout extends React.Component {}`; like a
// function declaration it stays in place and is exported under
// the split name
if (!splitNode.id) {
throw new Error(
`Class declaration for "${SPLIT_TYPE}" must have an identifier.`,
)
}

splitMeta.shouldRemoveNode = false
splitMeta.localExporterIdent = splitNode.id.name
} else if (
Expand Down Expand Up @@ -1166,6 +1184,52 @@ export function compileCodeSplitVirtualRoute(
])
})

// Exported split values stay in the reference file, so `removeExports`
// dropped their declarations from this module. Another split node in
// the same group may still refer to one of them (`loader: () =>
// Layout.name` next to an exported `Layout` component), so import those
// from the reference file.
if (knownExportedIdents.size > 0) {
programPath.scope.crawl()
const unresolvedExportedIdents = new Set<string>()
programPath.traverse({
ReferencedIdentifier(identPath) {
const { name } = identPath.node
if (
knownExportedIdents.has(name) &&
!identPath.scope.getBinding(name)
) {
unresolvedExportedIdents.add(name)
}
},
})

if (unresolvedExportedIdents.size > 0) {
const [importPath] = programPath.unshiftContainer(
'body',
t.importDeclaration(
[...unresolvedExportedIdents].map((name) =>
t.importSpecifier(t.identifier(name), t.identifier(name)),
),
t.stringLiteral(
removeSplitSearchParamFromFilename(opts.filename),
),
),
)

importPath.traverse({
Identifier(identPath) {
if (
identPath.parentPath.isImportSpecifier() &&
identPath.key === 'local'
) {
refIdents.add(identPath)
}
},
})
}
}

// convert exports to imports from the original file
programPath.traverse({
ExportNamedDeclaration(path) {
Expand Down Expand Up @@ -1622,14 +1686,22 @@ function hasExport(ast: t.File, node: t.Identifier): boolean {
})
}

// declared as `function loaderFn() {}`
if (t.isFunctionDeclaration(path.node.declaration)) {
// declared as `function loaderFn() {}` or `class Layout {}`
if (
t.isFunctionDeclaration(path.node.declaration) ||
t.isClassDeclaration(path.node.declaration)
) {
if (t.isIdentifier(path.node.declaration.id)) {
if (path.node.declaration.id.name === node.name) {
found = true
}
}
}
} else if (!path.node.source) {
// declared as `export { loaderFn }` or `export { loaderFn as load }`
if (hasLocalExportSpecifier(path.node, node.name)) {
found = true
}
}
},
ExportDefaultDeclaration(path) {
Expand All @@ -1640,8 +1712,11 @@ function hasExport(ast: t.File, node: t.Identifier): boolean {
}
}

// declared as `export default function loaderFn() {}`
if (t.isFunctionDeclaration(path.node.declaration)) {
// declared as `export default function loaderFn() {}` or `export default class Layout {}`
if (
t.isFunctionDeclaration(path.node.declaration) ||
t.isClassDeclaration(path.node.declaration)
) {
if (t.isIdentifier(path.node.declaration.id)) {
if (path.node.declaration.id.name === node.name) {
found = true
Expand All @@ -1654,6 +1729,22 @@ function hasExport(ast: t.File, node: t.Identifier): boolean {
return found
}

/**
* Whether an `export { ... }` statement without a source exports the given
* local binding, under its own name or an alias.
*/
function hasLocalExportSpecifier(
node: t.ExportNamedDeclaration,
name: string,
): boolean {
return node.specifiers.some(
(specifier) =>
t.isExportSpecifier(specifier) &&
t.isIdentifier(specifier.local) &&
specifier.local.name === name,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

function removeExports(ast: t.File, node: t.Identifier): boolean {
let removed = false

Expand Down Expand Up @@ -1687,15 +1778,36 @@ function removeExports(ast: t.File, node: t.Identifier): boolean {
}
}
})
} else if (t.isFunctionDeclaration(path.node.declaration)) {
// declared as `export const loaderFn = () => {}`
} else if (
t.isFunctionDeclaration(path.node.declaration) ||
t.isClassDeclaration(path.node.declaration)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
) {
// declared as `export function loaderFn() {}` or `export class Layout {}`
if (t.isIdentifier(path.node.declaration.id)) {
if (path.node.declaration.id.name === node.name) {
path.remove()
removed = true
}
}
}
} else if (!path.node.source) {
// declared as `export { loaderFn }` or `export { loaderFn as load }`
if (hasLocalExportSpecifier(path.node, node.name)) {
const specifiers = path.node.specifiers.filter(
(specifier) =>
!(
t.isExportSpecifier(specifier) &&
t.isIdentifier(specifier.local) &&
specifier.local.name === node.name
),
)
if (specifiers.length === 0) {
path.remove()
} else {
path.node.specifiers = specifiers
}
removed = true
}
}
},
ExportDefaultDeclaration(path) {
Expand All @@ -1705,8 +1817,11 @@ function removeExports(ast: t.File, node: t.Identifier): boolean {
path.remove()
removed = true
}
} else if (t.isFunctionDeclaration(path.node.declaration)) {
// declared as `export default function loaderFn() {}`
} else if (
t.isFunctionDeclaration(path.node.declaration) ||
t.isClassDeclaration(path.node.declaration)
) {
// declared as `export default function loaderFn() {}` or `export default class Layout {}`
if (t.isIdentifier(path.node.declaration.id)) {
if (path.node.declaration.id.name === node.name) {
path.remove()
Expand Down
107 changes: 107 additions & 0 deletions packages/router-plugin/tests/code-splitter-exports.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { createServer } from 'vite'
import { describe, expect, it } from 'vitest'
import { tanstackRouter } from '../src/vite'

// Loads a route file through the Vite dev pipeline with automatic code
// splitting on, then hands the module to the assertions while the server is
// still up, so lazy route options can import their split chunks.
async function withSplitRoute(
routeSource: string,
assert: (routeModule: Record<string, any>) => Promise<void> | void,
) {
const root = await mkdtemp(path.join(__dirname, '.split-exports-'))
let server: Awaited<ReturnType<typeof createServer>> | undefined
try {
await mkdir(path.join(root, 'routes'))
await writeFile(
path.join(root, 'routes/__root.tsx'),
`import { createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute({})`,
)
await writeFile(path.join(root, 'routes/page.tsx'), routeSource)
server = await createServer({
root,
configFile: false,
logLevel: 'silent',
plugins: [
tanstackRouter({
target: 'react',
autoCodeSplitting: true,
codeSplittingOptions: {
defaultBehavior: [['component'], ['loader']],
},
routesDirectory: './routes',
generatedRouteTree: './routeTree.gen.ts',
}),
],
server: { middlewareMode: true },
appType: 'custom',
})
await assert(await server.ssrLoadModule('/routes/page.tsx'))
} finally {
await server?.close()
await rm(root, { recursive: true, force: true })
}
}

describe('route exports under automatic code splitting', () => {
it('keeps a shared function with overload signatures importable and aliased', async () => {
await withSplitRoute(
`
import { createFileRoute } from '@tanstack/react-router'
export function helper(value: string): string
export function helper(value: number): number
export function helper(value: string | number) { return value }
export { helper as helperAlias }
export const Route = createFileRoute('/page')({
component: () => null,
beforeLoad: () => helper('before'),
loader: () => helper(42),
})`,
async (routeModule) => {
expect(routeModule.helper).toBeTypeOf('function')
expect(routeModule.helperAlias).toBe(routeModule.helper)
expect(routeModule.Route.options.beforeLoad()).toBe('before')
expect(await routeModule.Route.options.loader()).toBe(42)
},
)
}, 30_000)

it('retains an exported class component', async () => {
await withSplitRoute(
`
import { createFileRoute } from '@tanstack/react-router'
export class Layout { render() { return null } }
export const Route = createFileRoute('/page')({
component: Layout,
})`,
async (routeModule) => {
expect(routeModule.Layout).toBeTypeOf('function')
expect(routeModule.Route.options.component).toBe(routeModule.Layout)
},
)
}, 30_000)

it('retains route options exported under an alias', async () => {
await withSplitRoute(
`
import { createFileRoute } from '@tanstack/react-router'
function Layout() { return null }
function loadLayout() { return 'loaded' }
export { Layout as View, loadLayout as load }
export const Route = createFileRoute('/page')({
component: Layout,
loader: loadLayout,
})`,
async (routeModule) => {
expect(routeModule.View).toBeTypeOf('function')
expect(routeModule.Route.options.component).toBe(routeModule.View)
expect(routeModule.load).toBeTypeOf('function')
expect(routeModule.Route.options.loader).toBe(routeModule.load)
expect(routeModule.load()).toBe('loaded')
},
)
}, 30_000)
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as React from 'react'
import { createFileRoute, Outlet } from '@tanstack/react-router'
function Layout() {
return (
<main>
<Outlet />
</main>
)
}
function loadLayout() {
return { title: 'layout' }
}
export { Layout as View, loadLayout as load }
export const Route = createFileRoute('/_layout')({
component: Layout,
loader: loadLayout,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import * as React from 'react'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import * as React from 'react'
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import * as React from 'react'
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as React from 'react'
import { createFileRoute, Outlet } from '@tanstack/react-router'
export class Layout extends React.Component {
render() {
return (
<main>
<Outlet />
</main>
)
}
}
export const Route = createFileRoute('/_layout')({
component: Layout,
loader: () => Layout.name,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { Layout } from 'retain-exports-class-shared-group.tsx?tsr-shared=1'
export { Layout as component }
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as React from 'react'
import { Outlet } from '@tanstack/react-router'
class Layout extends React.Component {
render() {
return (
<main>
<Outlet />
</main>
)
}
}
export { Layout }
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as React from 'react'
import { createFileRoute, Outlet } from '@tanstack/react-router'
export class Layout extends React.Component {
render() {
return (
<main>
<Outlet />
</main>
)
}
}
export const Route = createFileRoute('/_layout')({ component: Layout })
Loading