From 5a83b5eea88948b2886ab6814710cd147014e895 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 16 Sep 2026 23:07:39 +0200 Subject: [PATCH] fix(router-plugin): preserve shared bindings during React HMR --- .changeset/bumpy-socks-find.md | 5 + .../plugins/react-refresh-route-components.ts | 15 +- .../1-default/shared-destructured-hmr.tsx | 9 + .../shared-destructured-hmr@component.tsx | 3 + ...shared-destructured-hmr@errorComponent.tsx | 0 ...red-destructured-hmr@notFoundComponent.tsx | 0 .../shared-destructured-hmr@shared.tsx | 6 + .../shared-destructured-hmr.tsx | 11 + ...--notFoundComponent---pendingComponent.tsx | 4 + .../shared-destructured-hmr@loader.tsx | 3 + .../shared-destructured-hmr@shared.tsx | 6 + .../shared-destructured-hmr.tsx | 11 + ...--notFoundComponent---pendingComponent.tsx | 10 + ...shared-destructured-hmr@errorComponent.tsx | 0 .../shared-destructured-hmr@shared.tsx | 0 .../react/shared-destructured-hmr.tsx | 10 + .../tests/shared-destructured-hmr.test.ts | 223 ++++++++++++++++++ 17 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 .changeset/bumpy-socks-find.md create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@component.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@errorComponent.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@notFoundComponent.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@shared.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@component---errorComponent---notFoundComponent---pendingComponent.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@loader.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@shared.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr@component---loader---notFoundComponent---pendingComponent.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr@errorComponent.tsx create mode 100644 packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr@shared.tsx create mode 100644 packages/router-plugin/tests/code-splitter/test-files/react/shared-destructured-hmr.tsx create mode 100644 packages/router-plugin/tests/shared-destructured-hmr.test.ts diff --git a/.changeset/bumpy-socks-find.md b/.changeset/bumpy-socks-find.md new file mode 100644 index 00000000000..88b754d43ad --- /dev/null +++ b/.changeset/bumpy-socks-find.md @@ -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. diff --git a/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts b/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts index 44041671e0f..2c7f10ed448 100644 --- a/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts +++ b/packages/router-plugin/src/core/code-splitter/plugins/react-refresh-route-components.ts @@ -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, +) { const hoistedDeclarations: Array = [] let modified = false @@ -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 } @@ -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 } } diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr.tsx new file mode 100644 index 00000000000..2c56d46f7db --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr.tsx @@ -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 +}); \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@component.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@component.tsx new file mode 100644 index 00000000000..8a78937dde6 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@component.tsx @@ -0,0 +1,3 @@ +import { render } from "shared-destructured-hmr.tsx?tsr-shared=1"; +const SplitComponent = () => render(); +export { SplitComponent as component }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@notFoundComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@notFoundComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@shared.tsx new file mode 100644 index 00000000000..a44244cf063 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/1-default/shared-destructured-hmr@shared.tsx @@ -0,0 +1,6 @@ +import { makeHelpers } from '../helpers'; +const { + read, + render +} = makeHelpers(); +export { read, render }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr.tsx new file mode 100644 index 00000000000..17a5787b40a --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr.tsx @@ -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') +}); \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@component---errorComponent---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@component---errorComponent---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..2c8391a352c --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@component---errorComponent---notFoundComponent---pendingComponent.tsx @@ -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 }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@loader.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@loader.tsx new file mode 100644 index 00000000000..8ac3619a01e --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@loader.tsx @@ -0,0 +1,3 @@ +import { read } from "shared-destructured-hmr.tsx?tsr-shared=1"; +const SplitLoader = () => read(); +export { SplitLoader as loader }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@shared.tsx new file mode 100644 index 00000000000..a44244cf063 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/2-components-combined-loader-separate/shared-destructured-hmr@shared.tsx @@ -0,0 +1,6 @@ +import { makeHelpers } from '../helpers'; +const { + read, + render +} = makeHelpers(); +export { read, render }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr.tsx new file mode 100644 index 00000000000..a5accf7d559 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr.tsx @@ -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') +}); \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr@component---loader---notFoundComponent---pendingComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr@component---loader---notFoundComponent---pendingComponent.tsx new file mode 100644 index 00000000000..bd3e39f8b9a --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr@component---loader---notFoundComponent---pendingComponent.tsx @@ -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 }; \ No newline at end of file diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr@errorComponent.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr@errorComponent.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr@shared.tsx b/packages/router-plugin/tests/code-splitter/snapshots/react/3-all-combined-errorComponent-separate/shared-destructured-hmr@shared.tsx new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/router-plugin/tests/code-splitter/test-files/react/shared-destructured-hmr.tsx b/packages/router-plugin/tests/code-splitter/test-files/react/shared-destructured-hmr.tsx new file mode 100644 index 00000000000..95eda07b1c0 --- /dev/null +++ b/packages/router-plugin/tests/code-splitter/test-files/react/shared-destructured-hmr.tsx @@ -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, +}) diff --git a/packages/router-plugin/tests/shared-destructured-hmr.test.ts b/packages/router-plugin/tests/shared-destructured-hmr.test.ts new file mode 100644 index 00000000000..ef78c759781 --- /dev/null +++ b/packages/router-plugin/tests/shared-destructured-hmr.test.ts @@ -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, + hot: { + data: Record + accept: ReturnType + dispose: ReturnType + }, +) { + const ast = parseAst({ code }) + const exports: Array = [] + 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 + // 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 = { + '@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() + }, + ) +})