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/bumpy-socks-find.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-plugin': patch
---

Preserve shared binding names during React route HMR so destructured component helpers are initialized once instead of causing duplicate declaration errors.
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ function getRouteComponentKey(prop: t.ObjectProperty) {
return key && REACT_REFRESH_ROUTE_COMPONENT_IDENTS.has(key) ? key : undefined
}

function prepareRouteComponentsForReactRefresh(ctx: RouteComponentContext) {
function prepareRouteComponentsForReactRefresh(
ctx: RouteComponentContext,
sharedBindings?: Set<string>,
) {
const hoistedDeclarations: Array<t.VariableDeclaration> = []
let modified = false

Expand All @@ -51,7 +54,13 @@ function prepareRouteComponentsForReactRefresh(ctx: RouteComponentContext) {
}

if (t.isIdentifier(prop.value)) {
if (isReactComponentName(prop.value.name)) {
// Shared bindings belong to the shared module, just like imports. Keep
// their names intact so extraction removes the original declaration and
// all chunks refer to the same exported binding/initialization.
if (
isReactComponentName(prop.value.name) ||
sharedBindings?.has(prop.value.name)
) {
continue
}

Expand Down Expand Up @@ -112,7 +121,7 @@ export function createReactRefreshRouteComponentsPlugin(): ReferenceRouteCompile
return [...REACT_REFRESH_ROUTE_COMPONENT_IDENTS]
},
onAddHmr(ctx) {
if (prepareRouteComponentsForReactRefresh(ctx)) {
if (prepareRouteComponentsForReactRefresh(ctx, ctx.opts.sharedBindings)) {
return { modified: true }
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { render, read } from "shared-destructured-hmr.tsx?tsr-shared=1";
const $$splitComponentImporter = () => import('shared-destructured-hmr.tsx?tsr-split=component');
import { lazyRouteComponent } from '@tanstack/react-router';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/hmr-probe')({
loader: () => read(),
component: lazyRouteComponent($$splitComponentImporter, 'component'),
pendingComponent: render
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { render } from "shared-destructured-hmr.tsx?tsr-shared=1";
const SplitComponent = () => render();
export { SplitComponent as component };
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { makeHelpers } from '../helpers';
const {
read,
render
} = makeHelpers();
export { read, render };
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const $$splitPendingComponentImporter = () => import('shared-destructured-hmr.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent');
const $$splitComponentImporter = () => import('shared-destructured-hmr.tsx?tsr-split=component---errorComponent---notFoundComponent---pendingComponent');
import { lazyRouteComponent } from '@tanstack/react-router';
const $$splitLoaderImporter = () => import('shared-destructured-hmr.tsx?tsr-split=loader');
import { lazyFn } from '@tanstack/react-router';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/hmr-probe')({
loader: lazyFn($$splitLoaderImporter, 'loader'),
component: lazyRouteComponent($$splitComponentImporter, 'component'),
pendingComponent: lazyRouteComponent($$splitPendingComponentImporter, 'pendingComponent')
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { render } from "shared-destructured-hmr.tsx?tsr-shared=1";
const SplitComponent = () => render();
export { SplitComponent as component };
export { render as pendingComponent };
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { read } from "shared-destructured-hmr.tsx?tsr-shared=1";
const SplitLoader = () => read();
export { SplitLoader as loader };
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { makeHelpers } from '../helpers';
const {
read,
render
} = makeHelpers();
export { read, render };
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const $$splitPendingComponentImporter = () => import('shared-destructured-hmr.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent');
const $$splitComponentImporter = () => import('shared-destructured-hmr.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent');
import { lazyRouteComponent } from '@tanstack/react-router';
const $$splitLoaderImporter = () => import('shared-destructured-hmr.tsx?tsr-split=component---loader---notFoundComponent---pendingComponent');
import { lazyFn } from '@tanstack/react-router';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/hmr-probe')({
loader: lazyFn($$splitLoaderImporter, 'loader'),
component: lazyRouteComponent($$splitComponentImporter, 'component'),
pendingComponent: lazyRouteComponent($$splitPendingComponentImporter, 'pendingComponent')
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { makeHelpers } from '../helpers';
const {
read,
render
} = makeHelpers();
const SplitLoader = () => read();
export { SplitLoader as loader };
const SplitComponent = () => render();
export { SplitComponent as component };
export { render as pendingComponent };
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
import { makeHelpers } from '../helpers'

const { read, render } = makeHelpers()

export const Route = createFileRoute('/hmr-probe')({
loader: () => read(),
component: () => render(),
pendingComponent: render,
})
223 changes: 223 additions & 0 deletions packages/router-plugin/tests/shared-destructured-hmr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import * as babel from '@babel/core'
import * as t from '@babel/types'
import * as router from '@tanstack/react-router'
import { generateFromAst, parseAst } from '@tanstack/router-utils'
import { createElement } from 'react'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { tanstackRouter } from '../src/vite'
import type { Plugin } from 'vite'

// Evaluate emitted modules with ordinary module imports and a persistent HMR
// data object. Only module syntax/import.meta are lowered; router code is real.
function evaluateModule(
code: string,
modules: Record<string, any>,
hot: {
data: Record<string, unknown>
accept: ReturnType<typeof vi.fn>
dispose: ReturnType<typeof vi.fn>
},
) {
const ast = parseAst({ code })
const exports: Array<t.ObjectProperty> = []
babel.traverse(ast, {
ImportDeclaration(importPath) {
const properties = importPath.node.specifiers.map((specifier) => {
t.assertImportSpecifier(specifier)
return t.objectProperty(specifier.imported, specifier.local)
})
importPath.replaceWith(
t.variableDeclaration('const', [
t.variableDeclarator(
t.objectPattern(properties),
t.memberExpression(
t.identifier('modules'),
importPath.node.source,
true,
),
),
]),
)
},
ExportNamedDeclaration(exportPath) {
const declaration = exportPath.node.declaration
if (declaration) {
for (const name of Object.keys(t.getBindingIdentifiers(declaration))) {
exports.push(t.objectProperty(t.identifier(name), t.identifier(name)))
}
exportPath.replaceWith(declaration)
} else {
for (const specifier of exportPath.node.specifiers) {
t.assertExportSpecifier(specifier)
exports.push(t.objectProperty(specifier.exported, specifier.local))
}
exportPath.remove()
}
},
MetaProperty(metaPath) {
metaPath.replaceWith(t.identifier('importMeta'))
},
CallExpression(callPath) {
if (t.isImport(callPath.node.callee)) {
callPath.node.callee = t.identifier('load')
}
},
})
ast.program.body.push(t.returnStatement(t.objectExpression(exports)))
return new Function(
'modules',
'importMeta',
'load',
generateFromAst(ast).code,
)(modules, { hot, webpackHot: hot }, (id: string) =>
Promise.resolve(modules[id]),
)
}

async function createCompiler(source: string, hmrStyle: 'vite' | 'webpack') {
const root = await mkdtemp(path.join(tmpdir(), 'router-shared-hmr-test-'))
onTestFinished(() => rm(root, { recursive: true, force: true }))
const routes = path.join(root, 'routes')
await mkdir(routes)
await writeFile(
path.join(routes, '__root.tsx'),
`import { createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute()`,
)
const filename = path.join(routes, 'hmr-probe.tsx')
await writeFile(filename, source)
const result = tanstackRouter({
target: 'react',
autoCodeSplitting: true,
routesDirectory: routes,
generatedRouteTree: path.join(root, 'routeTree.gen.ts'),
plugin: { hmr: { style: hmrStyle } },
})
const plugins = (Array.isArray(result) ? result : [result]) as Array<Plugin>
// The public generator populates route ownership from real source files.
// No routing context, shared-binding map or compiler state is modified here.
for (const plugin of plugins) {
const hook = plugin.configResolved
if (hook) {
const handler = typeof hook === 'function' ? hook : hook.handler
await handler.call(
{} as never,
{ root, command: 'serve', plugins } as never,
)
}
}
return {
filename,
async compile(
code: string,
kind: 'reference' | 'virtual' | 'shared',
query = '',
) {
const plugin = plugins.find(
(item) =>
item.name === `tanstack-router:code-splitter:compile-${kind}-file`,
)!
const transform = plugin.transform!
const handler =
typeof transform === 'function' ? transform : transform.handler
const result = await handler.call(
{} as never,
code,
`${filename}${query}`,
)
if (
!result ||
typeof result === 'string' ||
typeof result.code !== 'string'
) {
throw new Error(`Expected compiled ${kind} module`)
}
return result.code
},
}
}

describe('shared destructuring with React route HMR', () => {
it.each([
{ hmrStyle: 'vite', alias: false },
{ hmrStyle: 'webpack', alias: false },
{ hmrStyle: 'vite', alias: true },
{ hmrStyle: 'webpack', alias: true },
] as const)(
'initializes once and preserves identity with $hmrStyle HMR (alias=$alias)',
async ({ hmrStyle, alias }) => {
const fixture = await readFile(
new URL(
'./code-splitter/test-files/react/shared-destructured-hmr.tsx',
import.meta.url,
),
'utf8',
)
const source = alias
? fixture
.replace('{ read, render }', '{ read, render: pending }')
.replace('() => render()', '() => pending()')
.replace('pendingComponent: render', 'pendingComponent: pending')
: fixture
const compiler = await createCompiler(source, hmrStyle)
const referenceCode = await compiler.compile(source, 'reference')
const sharedCode = await compiler.compile(
source,
'shared',
'?tsr-shared=1',
)
const virtualCode = await compiler.compile(
source,
'virtual',
'?tsr-split=component',
)
const makeHelpers = vi.fn(() => {
const state = { value: 'shared instance' }
return {
read: () => state,
render: () => createElement('div', { 'data-state': state }),
}
})
const modules: Record<string, any> = {
'@tanstack/react-router': router,
'../helpers': { makeHelpers },
}
const hot = { data: {}, accept: vi.fn(), dispose: vi.fn() }
const shared = evaluateModule(sharedCode, modules, hot)
modules[`${compiler.filename}?tsr-shared=1`] = shared
const split = evaluateModule(virtualCode, modules, hot)
modules[`${compiler.filename}?tsr-split=component`] = split
const { Route } = evaluateModule(referenceCode, modules, hot)
const state = Route.options.loader()
expect(Route.options.pendingComponent).toBe(
shared[alias ? 'pending' : 'render'],
)
expect(Route.options.pendingComponent().props['data-state']).toBe(state)
expect(split.component().props['data-state']).toBe(state)
await Route.options.component.preload()
expect(Route.options.component({}).type).toBe(split.component)
expect(makeHelpers).toHaveBeenCalledTimes(1)

const updated = source.replace(
'loader: () => read()',
'loader: () => ({ state: read(), updated: true })',
)
const updatedCode = await compiler.compile(updated, 'reference')
expect(await compiler.compile(updated, 'shared', '?tsr-shared=1')).toBe(
sharedCode,
)
const { Route: refreshed } = evaluateModule(updatedCode, modules, hot)
expect(refreshed.options.loader()).toEqual({ state, updated: true })
expect(refreshed.options.loader().state).toBe(state)
expect(refreshed.options.component).toBe(Route.options.component)
expect(refreshed.options.pendingComponent).toBe(
Route.options.pendingComponent,
)
expect(makeHelpers).toHaveBeenCalledTimes(1)
expect(hot.accept).toHaveBeenCalled()
},
)
})
Loading