diff --git a/.changeset/fruity-pears-see.md b/.changeset/fruity-pears-see.md new file mode 100644 index 00000000000..c4a30b0a177 --- /dev/null +++ b/.changeset/fruity-pears-see.md @@ -0,0 +1,8 @@ +--- +'@tanstack/router-plugin': minor +'@tanstack/router-generator': minor +--- + +Add a native Bun plugin with file-based route generation and automatic code splitting, including shared route modules. Reuse Router HMR and expose resolved generator watch dependencies for native development-server watches. + +Reload edited virtual route configurations and their TypeScript imports when regenerating the route tree. diff --git a/.github/workflows/bun.yml b/.github/workflows/bun.yml new file mode 100644 index 00000000000..661c00382d0 --- /dev/null +++ b/.github/workflows/bun.yml @@ -0,0 +1,77 @@ +name: Bun Router + +on: + pull_request: + paths: + - 'packages/router-plugin/**' + - 'e2e/react-router/bun-basic-file-based/**' + - 'e2e/e2e-utils/**' + - 'packages/router-generator/**' + - 'packages/router-utils/**' + - 'packages/virtual-file-routes/**' + - 'packages/router-core/**' + - 'packages/react-router/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'nx.json' + - 'tsconfig.json' + - 'scripts/nx/**' + - '.npmrc' + - '.nvmrc' + - '.github/workflows/bun.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CI: '1' + NX_DAEMON: 'false' + NX_NO_CLOUD: 'true' + +jobs: + router: + name: Bun Router (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Setup pnpm + uses: pnpm/action-setup@739bfe42ca9233c5e6aca07c1a25a9d34aca49b0 # v6.0.7 + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + - name: Install dependencies + run: >- + pnpm install --frozen-lockfile + --filter . + --filter "@tanstack/router-plugin..." + --filter "tanstack-router-e2e-react-bun-basic-file-based..." + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: '1.4.3' + - name: Install Chromium (Linux) + if: runner.os == 'Linux' + run: pnpm exec playwright install --with-deps chromium --only-shell + - name: Install Chromium (Windows and macOS) + if: runner.os != 'Linux' + run: pnpm exec playwright install chromium --only-shell + - name: Test production builds and navigation + run: pnpm nx run tanstack-router-e2e-react-bun-basic-file-based:test:e2e:bun --outputStyle=stream --skipRemoteCache diff --git a/e2e/react-router/bun-basic-file-based/build.mjs b/e2e/react-router/bun-basic-file-based/build.mjs new file mode 100644 index 00000000000..3c5e82c5ad5 --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/build.mjs @@ -0,0 +1,318 @@ +import { cp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import tanstackRouter, { + tanstackRouter as namedTanstackRouter, +} from '@tanstack/router-plugin/bun' +import { verifyBunRouteSourceMaps } from './source-maps.mjs' +import { index, physical, rootRoute } from '@tanstack/virtual-file-routes' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const workspace = path.resolve(here, './.cache/bun-e2e') +const fixture = path.join(workspace, 'fixture') +const generatedRouteTree = path.join(fixture, 'routeTree.gen.ts') + +await rm(workspace, { recursive: true, force: true }) +await mkdir(workspace, { recursive: true }) +await cp(path.join(here, 'fixture'), fixture, { recursive: true }) + +const options = { + target: 'react', + routesDirectory: path.join(fixture, 'routes'), + generatedRouteTree, + tmpDir: path.join(workspace, 'tmp'), + autoCodeSplitting: true, + disableLogging: true, +} + +const markers = [ + 'HOME_NATIVE_BUN', + 'ABOUT_NATIVE_BUN', + 'DETAILS_DYNAMIC_BUN', + 'SECOND_BUILD_NATIVE_BUN', + 'EDITED_NATIVE_BUN', +] + +const supportsInputSourceMaps = await verifyBunRouteSourceMaps() + +async function build(name, plugin, beforePlugins = [], paths = {}) { + const entryFixture = paths.fixture ?? fixture + const treeFile = paths.generatedRouteTree ?? generatedRouteTree + const result = await Bun.build({ + entrypoints: [path.join(entryFixture, 'main.tsx')], + outdir: path.join(workspace, name), + target: 'browser', + format: 'esm', + splitting: true, + minify: true, + sourcemap: 'external', + loader: { '.js': 'jsx' }, + define: { 'process.env.NODE_ENV': '"production"' }, + plugins: [...beforePlugins, plugin], + }) + if (!result.success) { + throw new Error(result.logs.map(String).join('\n')) + } + const outputs = await Promise.all( + result.outputs.map(async (output) => ({ + path: path + .relative(path.join(workspace, name), output.path) + .replaceAll('\\', '/'), + kind: output.kind, + source: await output.text(), + })), + ) + const routeSource = await readFile( + path.join(entryFixture, 'routes/about.tsx'), + 'utf8', + ) + return { + name, + routeTree: await readFile(treeFile, 'utf8'), + routeSourceMaps: outputs + .filter( + ({ path, source }) => + path.endsWith('.js') && source.includes('ABOUT_NATIVE_BUN'), + ) + .map((output) => { + const prefix = output.source + .slice(0, output.source.indexOf('ABOUT_NATIVE_BUN')) + .split('\n') + return { + payload: JSON.parse( + outputs.find(({ path }) => path === `${output.path}.map`).source, + ), + generatedLine: prefix.length - 1, + generatedColumn: prefix.at(-1).length, + originalSource: routeSource, + } + }), + outputs: outputs.map(({ source, ...output }) => ({ + ...output, + markers: output.path.endsWith('.js') + ? markers.filter((marker) => source.includes(marker)) + : [], + })), + } +} + +const reusedPlugin = tanstackRouter({ + ...options, + codeSplittingOptions: { defaultBehavior: [['loader'], ['component']] }, +}) +const builds = [await build('initial', reusedPlugin)] + +await writeFile( + path.join(fixture, 'routes/second build.js'), + `import { createFileRoute } from '@tanstack/react-router' +const shared = { value: 42 } +export const Route = createFileRoute('/second build')({ + loader: () => shared, + component: () =>

SECOND_BUILD_NATIVE_BUN

, +}) +`, +) +builds.push(await build('reused', reusedPlugin)) + +// Hold A after reference compilation, then start B while A still needs its +// shared bindings. Only public Bun hooks are used to control the overlap. +const aAtResolve = Promise.withResolvers() +const bStarted = Promise.withResolvers() +const releaseB = Promise.withResolvers() +const concurrentA = build('concurrent-a', reusedPlugin, [ + { + name: 'pause-before-split-resolution', + setup(build) { + build.onEnd(() => { + releaseB.resolve() + }) + build.onResolve( + { filter: /about\.tsx\?tsr-(split|shared)=/ }, + async () => { + aAtResolve.resolve() + await bStarted.promise + }, + ) + }, + }, +]) +try { + await Promise.race([ + aAtResolve.promise, + concurrentA.then(() => { + throw new Error( + 'Concurrent build completed without resolving route chunks', + ) + }), + ]) + const concurrentB = build('concurrent-b', reusedPlugin, [ + { + name: 'pause-concurrent-build', + setup(build) { + build.onStart(() => { + bStarted.resolve() + return releaseB.promise + }) + }, + }, + ]) + builds.push(...(await Promise.all([concurrentA, concurrentB]))) +} finally { + bStarted.resolve() + releaseB.resolve() +} + +await rm(generatedRouteTree) +builds.push(await build('independent', namedTanstackRouter(options))) + +// Both files are valid TSX. A stale tree would still bundle successfully, so +// this conflict specifically verifies that generation errors reject the build. +await mkdir(path.join(fixture, 'routes/(duplicate)'), { recursive: true }) +await writeFile( + path.join(fixture, 'routes/(duplicate)/about.tsx'), + `import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/(duplicate)/about')({}) +`, +) +let generationFailure +try { + await build('invalid', reusedPlugin) +} catch (error) { + generationFailure = + error instanceof AggregateError + ? [String(error), ...Array.from(error.errors, String)].join('\n') + : String(error) +} +await rm(path.join(fixture, 'routes/(duplicate)/about.tsx')) +builds.push(await build('recovered', reusedPlugin)) + +await writeFile( + path.join(fixture, 'routes/about.tsx'), + `import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/about')({ + component: () =>

EDITED_NATIVE_BUN

, +}) +`, +) +await rm(path.join(fixture, 'routes/details.tsx')) +await rm(path.join(fixture, 'routes/details.lazy.tsx')) +builds.push(await build('edited', reusedPlugin)) + +const aliasBuilds = [] +for (const name of [ + 'project-alias', + 'routes-alias', + 'tree-alias', + 'mounted-alias', +]) { + const directory = path.join(workspace, name) + const source = path.join(directory, 'source') + const alias = path.join(directory, 'alias') + await cp(path.join(here, 'fixture'), source, { recursive: true }) + await symlink( + source, + alias, + process.platform === 'win32' ? 'junction' : 'dir', + ) + const routesDirectory = path.join( + name === 'project-alias' || name === 'routes-alias' ? alias : source, + 'routes', + ) + const treeFile = path.join( + name === 'project-alias' || name === 'tree-alias' ? alias : source, + 'routeTree.gen.ts', + ) + let virtualRouteConfig + if (name === 'mounted-alias') { + const mounted = path.join(directory, 'mounted') + await mkdir(mounted) + await cp( + path.join(source, 'routes/about.tsx'), + path.join(mounted, 'about.tsx'), + ) + await symlink( + mounted, + path.join(source, 'mounted-alias'), + process.platform === 'win32' ? 'junction' : 'dir', + ) + virtualRouteConfig = rootRoute('__root.tsx', [ + index('index.tsx'), + physical('../mounted-alias'), + ]) + } + aliasBuilds.push( + await build( + name, + tanstackRouter({ + ...options, + routesDirectory, + generatedRouteTree: treeFile, + tmpDir: path.join(directory, 'tmp'), + virtualRouteConfig, + codeSplittingOptions: { defaultBehavior: [['loader'], ['component']] }, + }), + [], + { + fixture: name === 'project-alias' ? alias : source, + generatedRouteTree: treeFile, + }, + ), + ) +} + +const manifest = { + builds, + aliasBuilds, + generationFailure, + supportsInputSourceMaps, +} +if (!process.argv.includes('--serve')) { + console.log(JSON.stringify(manifest, null, 2)) +} else { + const servedBuilds = builds.filter((build) => + ['concurrent-a', 'concurrent-b', 'independent', 'recovered'].includes( + build.name, + ), + ) + const entrypoints = new Map( + servedBuilds.map((build) => [ + build.name, + build.outputs.find((output) => output.kind === 'entry-point').path, + ]), + ) + const assets = new Map( + servedBuilds.flatMap((build) => + build.outputs.map((output) => [ + `/${build.name}/${output.path}`, + Bun.file(path.join(workspace, build.name, output.path)), + ]), + ), + ) + const server = Bun.serve({ + hostname: '127.0.0.1', + port: Number(process.env.E2E_APP_PORT ?? 0), + fetch(request) { + const url = new URL(request.url) + const pathname = url.pathname + if (pathname === '/__builds') { + return Response.json(manifest) + } + const asset = assets.get(pathname) + if (asset) { + return new Response(asset, { + headers: { 'Content-Type': 'text/javascript' }, + }) + } + const buildName = url.searchParams.get('build') ?? 'recovered' + const entrypoint = entrypoints.get(buildName) + if (!entrypoint) { + return new Response('Unknown build', { status: 404 }) + } + return new Response( + `
`, + { headers: { 'Content-Type': 'text/html' } }, + ) + }, + }) + console.log(`E2E app: ${server.url}`) +} diff --git a/e2e/react-router/bun-basic-file-based/bun.spec.ts b/e2e/react-router/bun-basic-file-based/bun.spec.ts new file mode 100644 index 00000000000..7703b66afb4 --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/bun.spec.ts @@ -0,0 +1,203 @@ +import { expect } from '@playwright/test' +import { test } from '@tanstack/router-e2e-utils' +import { SourceMap } from 'node:module' +import type { SourceMapPayload } from 'node:module' + +const markers = { + Home: 'HOME_NATIVE_BUN', + About: 'ABOUT_NATIVE_BUN', + Details: 'DETAILS_DYNAMIC_BUN', + 'Second build': 'SECOND_BUILD_NATIVE_BUN', +} + +type Build = { + name: string + routeTree: string + routeSourceMaps: Array<{ + payload: SourceMapPayload + generatedLine: number + generatedColumn: number + originalSource: string + }> + outputs: Array<{ path: string; kind: string; markers: Array }> +} + +test('generates routes before production bundling and refreshes reused plugins', async ({ + request, +}) => { + const response = await request.get('/__builds') + expect(response.ok()).toBe(true) + const manifest = (await response.json()) as { + builds: Array + generationFailure?: string + } + expect(manifest.builds.map((build) => build.name)).toEqual([ + 'initial', + 'reused', + 'concurrent-a', + 'concurrent-b', + 'independent', + 'recovered', + 'edited', + ]) + for (const build of manifest.builds) { + expect(build.routeTree).toContain('/about') + if (build.name === 'edited') { + expect(build.routeTree).not.toContain('/details') + } else { + expect(build.routeTree).toContain('/details') + } + const entrypoint = build.outputs.find( + (output) => output.kind === 'entry-point', + ) + expect(entrypoint).toBeDefined() + expect(entrypoint!.markers).toEqual([]) + const expectedMarkers = + build.name === 'initial' + ? [markers.Home, markers.About, markers.Details] + : build.name === 'edited' + ? [markers.Home, markers['Second build'], 'EDITED_NATIVE_BUN'] + : Object.values(markers) + expect(build.outputs.flatMap((output) => output.markers).sort()).toEqual( + [...expectedMarkers].sort(), + ) + for (const marker of expectedMarkers) { + expect( + build.outputs.filter((output) => output.markers.includes(marker)), + ).toEqual([expect.objectContaining({ kind: 'chunk', markers: [marker] })]) + } + if (build.name !== 'initial') { + expect(build.routeTree).toContain('/second build') + } + } + expect(manifest.generationFailure).toBeTruthy() + expect(manifest.generationFailure).toMatch(/conflicting configuration paths/i) +}) + +test('splits routes through project, route-directory, generated-tree and mounted aliases', async ({ + request, +}) => { + const manifest = (await (await request.get('/__builds')).json()) as { + aliasBuilds: Array + } + expect(manifest.aliasBuilds.map(({ name }) => name)).toEqual([ + 'project-alias', + 'routes-alias', + 'tree-alias', + 'mounted-alias', + ]) + for (const build of manifest.aliasBuilds) { + expect(build.routeTree).toContain('/about') + const entrypoint = build.outputs.find(({ kind }) => kind === 'entry-point') + expect(entrypoint).toBeDefined() + expect(entrypoint!.markers).toEqual([]) + expect( + build.outputs.filter(({ markers }) => + markers.includes('ABOUT_NATIVE_BUN'), + ), + ).toEqual([ + expect.objectContaining({ kind: 'chunk', markers: ['ABOUT_NATIVE_BUN'] }), + ]) + } +}) + +test('maps split route chunks to their authored source', async ({ + request, +}) => { + const manifest = (await (await request.get('/__builds')).json()) as { + builds: Array + supportsInputSourceMaps: boolean + } + const maps = manifest.builds + .filter(({ name }) => name !== 'edited') + .flatMap(({ routeSourceMaps }) => routeSourceMaps) + expect(maps).toHaveLength(6) + test.skip( + !manifest.supportsInputSourceMaps, + 'Bun does not compose input source maps: https://github.com/oven-sh/bun/issues/6173', + ) + for (const sourceMap of maps) { + const entry = new SourceMap(sourceMap.payload).findEntry( + sourceMap.generatedLine, + sourceMap.generatedColumn, + ) + expect(entry.originalSource).toMatch(/\/routes\/about\.tsx$/) + const sourceIndex = sourceMap.payload.sources.indexOf(entry.originalSource!) + expect(sourceMap.payload.sourcesContent?.[sourceIndex]).toBe( + sourceMap.originalSource, + ) + // Bun maps JSX text to its enclosing element, at line 21, column 4. + expect(entry.originalLine).toBe(20) + expect(entry.originalColumn).toBe(4) + } +}) + +for (const buildName of [ + 'concurrent-a', + 'concurrent-b', + 'independent', + 'recovered', +]) { + test(`${buildName}: loads route chunks on navigation and preserves shared module identity`, async ({ + page, + request, + }) => { + const manifest = (await (await request.get('/__builds')).json()) as { + builds: Array + } + const build = manifest.builds.find((build) => build.name === buildName)! + const requested = new Set() + const errors: Array = [] + page.on('request', (request) => { + requested.add(new URL(request.url()).pathname.slice(1)) + }) + page.on('pageerror', (error) => { + errors.push(error.message) + }) + page.on('console', (message) => { + if (message.type() === 'error') { + errors.push(message.text()) + } + }) + const chunkFor = (marker: string) => + `${buildName}/${build.outputs.find((output) => output.markers.includes(marker))!.path}` + + await page.goto(`/?build=${buildName}`) + await expect(page.getByRole('heading')).toHaveText(markers.Home) + expect(requested.has(chunkFor(markers.Home))).toBe(true) + expect(requested.has(chunkFor(markers.About))).toBe(false) + expect(requested.has(chunkFor(markers.Details))).toBe(false) + expect(requested.has(chunkFor(markers['Second build']))).toBe(false) + + for (const name of ['About', 'Details', 'Home', 'About'] as const) { + await page.getByRole('link', { name, exact: true }).click() + await expect(page.getByRole('heading')).toContainText(markers[name]) + expect(requested.has(chunkFor(markers[name]))).toBe(true) + if (name === 'About') { + await expect(page.getByRole('heading')).toHaveAttribute( + 'data-shared-identity', + 'true', + ) + await expect(page.getByRole('heading')).toHaveText('ABOUT_NATIVE_BUN 1') + } + } + await page.getByRole('link', { name: 'Second build', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText(markers['Second build']) + await expect(page.getByRole('heading')).toHaveAttribute( + 'data-shared-identity', + 'true', + ) + expect(requested.has(chunkFor(markers['Second build']))).toBe(true) + expect( + await page.evaluate( + () => + ( + globalThis as typeof globalThis & { + __bunSharedInitializations?: number + } + ).__bunSharedInitializations, + ), + ).toBe(1) + expect(errors).toEqual([]) + }) +} diff --git a/e2e/react-router/bun-basic-file-based/dev-fixture.ts b/e2e/react-router/bun-basic-file-based/dev-fixture.ts new file mode 100644 index 00000000000..46def3758f2 --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/dev-fixture.ts @@ -0,0 +1,342 @@ +import { spawn, execFileSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { + mkdir, + mkdtemp, + readFile, + rename, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import path from 'node:path' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' + +export function aboutSource( + component = 'ABOUT_BEFORE', + loader = 'LOADER_BEFORE', + map = 'AUTHORED_BEFORE', +) { + return `import { useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +const state = globalThis as typeof globalThis & { __bunDevSharedInitializations?: number } +const shared = { initialization: (state.__bunDevSharedInitializations = (state.__bunDevSharedInitializations ?? 0) + 1) } +export const Route = createFileRoute('/about')({ + loader: () => ({ shared, label: '${loader}' }), + component: About, +}) +function About() { + const [count, setCount] = useState(0) + const data = Route.useLoaderData() + function authoredProbe() { + throw new Error('${map}') + } + return
+

${component}

+ + {data.label} + +
+} +` +} + +export const nestedSource = (id: string, marker: string) => + `import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('${id}')({ component: () =>

${marker}

});\n` + +export const virtualConfigSource = (included: 'before' | 'after') => + `import { rootRoute, index, route } from '@tanstack/virtual-file-routes'; export const routes = rootRoute('__root.tsx', [index('index.tsx'), route('/about', 'about.tsx'), route('/virtual-${included}', '../virtual-pages/${included}.tsx')]);\n` + +export type InlineMapEvent = { id: string; contents: string } + +export async function startDevFixture({ + generatedExtension = 'ts', + virtualMount = false, + virtualConfigFile = false, + initialVirtualConfig, + virtualConfigHelperDepth = 0, + autoCodeSplitting = true, + projectAlias = false, +}: { + generatedExtension?: string + virtualMount?: boolean + virtualConfigFile?: boolean + initialVirtualConfig?: string + virtualConfigHelperDepth?: 0 | 1 | 2 + autoCodeSplitting?: boolean + projectAlias?: boolean +} = {}) { + const executable = process.env.BUN_EXECUTABLE + const repo = process.env.ROUTER_REPOSITORY_ROOT + if (!executable || !repo) { + throw new Error('Use run-dev.mjs with BUN_EXECUTABLE set') + } + const requirePlugin = createRequire( + path.join(repo, 'packages/router-plugin/package.json'), + ) + const requireReact = createRequire( + path.join(repo, 'packages/react-router/package.json'), + ) + const requireProject = createRequire( + fileURLToPath(new URL('./package.json', import.meta.url)), + ) + const container = await mkdtemp(path.join(tmpdir(), 'router-bun-native-dev-')) + const directory = projectAlias ? path.join(container, 'alias') : container + const inlineMaps: Array = [] + let logs = '' + let child: ReturnType | undefined + const file = (name: string) => path.join(directory, name) + const save = async (name: string, source: string) => { + await mkdir(path.dirname(file(name)), { recursive: true }) + await writeFile(file(name), source) + } + try { + if (projectAlias) { + const source = path.join(container, 'source') + await mkdir(source) + await symlink( + source, + directory, + process.platform === 'win32' ? 'junction' : 'dir', + ) + } + for (const name of [ + '@tanstack/router-plugin', + '@tanstack/react-router', + '@tanstack/virtual-file-routes', + 'react', + 'react-dom', + ]) { + const target = name.startsWith('@tanstack/') + ? path.join(repo, 'packages', name.slice('@tanstack/'.length)) + : path.dirname(requireReact.resolve(`${name}/package.json`)) + const destination = file(`node_modules/${name}`) + await mkdir(path.dirname(destination), { recursive: true }) + await symlink( + target, + destination, + process.platform === 'win32' ? 'junction' : 'dir', + ) + } + await mkdir(file('routes/nested/empty/deep'), { recursive: true }) + await mkdir(file('routes/(conflict)'), { recursive: true }) + await mkdir(file('mounted/empty/deep'), { recursive: true }) + await save( + 'routes/__root.tsx', + `import { createRootRoute, Link, Outlet } from '@tanstack/react-router'; export const Route = createRootRoute({ notFoundComponent: () =>

NOT_FOUND

, component: () => <> });`, + ) + await save('routes/index.tsx', nestedSource('/', 'HOME_NATIVE_DEV')) + await save('routes/about.tsx', aboutSource()) + if (virtualConfigFile) { + for (let index = 0; index < virtualConfigHelperDepth; index++) { + await save( + index ? `config/helper${index}.ts` : 'virtual-routes.ts', + `export { routes } from '${index ? './' : './config/'}helper${index + 1}.ts'\n`, + ) + } + await save( + virtualConfigHelperDepth + ? `config/helper${virtualConfigHelperDepth}.ts` + : 'virtual-routes.ts', + initialVirtualConfig ?? virtualConfigSource('before'), + ) + await save( + 'virtual-pages/before.tsx', + nestedSource('/virtual-before', 'VIRTUAL_BEFORE'), + ) + await save( + 'virtual-pages/after.tsx', + nestedSource('/virtual-after', 'VIRTUAL_AFTER'), + ) + } + await save( + 'main.tsx', + `import { createRoot } from 'react-dom/client'; import { createRouter, RouterProvider } from '@tanstack/react-router'; import { routeTree } from './routeTree.gen.${generatedExtension}'; const router = createRouter({ routeTree, defaultPreload: false }); createRoot(document.getElementById('app')!).render();`, + ) + await save( + 'index.html', + '
', + ) + await save( + 'tsconfig.json', + JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }), + ) + await save( + 'bunfig.toml', + '[serve.static]\nplugins = ["./plugin.ts"]\nsourcemap = "linked"\n', + ) + const options = { + target: 'react', + routesDirectory: file('routes'), + generatedRouteTree: file(`routeTree.gen.${generatedExtension}`), + tmpDir: file('tmp'), + autoCodeSplitting, + disableLogging: true, + codeSplittingOptions: { defaultBehavior: [['loader'], ['component']] }, + } + await save( + 'plugin.ts', + `${virtualMount ? `import { rootRoute, index, route, physical } from '@tanstack/virtual-file-routes';` : ''} +const { default: router } = await import('@tanstack/router-plugin/bun'); +const options = ${JSON.stringify(options)}; +${virtualMount ? `options.virtualRouteConfig = rootRoute('__root.tsx', [index('index.tsx'), route('/about', 'about.tsx'), physical('../mounted')]);` : ''} +${virtualConfigFile ? `options.virtualRouteConfig = 'virtual-routes.ts';` : ''} +const plugin = router(options); +export default { name: 'observe-public-router-dev', setup(build) { + return plugin.setup(new Proxy(build, { get(target, key) { + if (key === 'onLoad') return (options, callback) => target.onLoad(options, async args => { + const result = await callback(args); + if (typeof result?.contents === 'string' && args.path.replaceAll('\\\\', '/').includes('/routes/')) { + console.log('ROUTER_INLINE_MAP ' + JSON.stringify({ id: args.path, contents: result.contents })); + } + return result; + }); + const value = Reflect.get(target, key); + return typeof value === 'function' ? value.bind(target) : value; + } })); +} }; +`, + ) + await save( + 'server.ts', + `import html from './index.html'; const server = Bun.serve({ hostname: '127.0.0.1', port: 0, development: { hmr: true }, routes: { '/__native-info': () => Response.json({ pid: process.pid }), '/*': html } }); console.log('ROUTER_READY ' + JSON.stringify({ url: server.url.href, pid: process.pid }));`, + ) + const preload = process.env.BUN_PLUGIN_PRELOAD + const preloadArg = preload + ? preload.startsWith('file:') + ? fileURLToPath(preload) + : path.resolve(preload) + : undefined + let pending = '' + let settleReady: (value: { url: string; pid: number }) => void + let rejectReady: (error: Error) => void + const ready = new Promise<{ url: string; pid: number }>( + (resolve, reject) => { + settleReady = resolve + rejectReady = reject + }, + ) + child = spawn( + executable, + [...(preloadArg ? ['--preload', preloadArg] : []), 'server.ts'], + { + cwd: directory, + env: { ...process.env, NODE_ENV: 'development' }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ) + child.on('error', (error) => rejectReady(error)) + child.on('exit', (code, signal) => + rejectReady( + new Error(`Bun exited before readiness (${code}, ${signal})\n${logs}`), + ), + ) + child.stdout!.on('data', (chunk) => { + logs = (logs + chunk).slice(-2_000_000) + pending += chunk.toString() + let newline: number + while ((newline = pending.indexOf('\n')) !== -1) { + const line = pending.slice(0, newline) + pending = pending.slice(newline + 1) + try { + if (line.startsWith('ROUTER_READY ')) { + settleReady(JSON.parse(line.slice('ROUTER_READY '.length))) + } + if (line.startsWith('ROUTER_INLINE_MAP ')) { + inlineMaps.push(JSON.parse(line.slice('ROUTER_INLINE_MAP '.length))) + } + } catch (error) { + rejectReady(new Error(`Invalid fixture event: ${error}`)) + } + } + }) + child.stderr!.on('data', (chunk) => { + logs = (logs + chunk).slice(-2_000_000) + }) + let timeout: ReturnType + const info = await Promise.race([ + ready, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error(`Bun readiness deadline\n${logs}`)), + 20_000, + ) + }), + ]).finally(() => clearTimeout(timeout!)) + const versionOf = async ( + name: string, + requireFrom: ReturnType, + ) => + JSON.parse( + await readFile(requireFrom.resolve(`${name}/package.json`), 'utf8'), + ).version as string + const versions = { + bun: execFileSync(executable, ['--version'], { encoding: 'utf8' }).trim(), + revision: execFileSync(executable, ['--revision'], { + encoding: 'utf8', + }).trim(), + node: process.version, + routerPlugin: await versionOf('@tanstack/router-plugin', requirePlugin), + reactRouter: await versionOf('@tanstack/react-router', requireReact), + react: await versionOf('react', requireReact), + unplugin: await versionOf('unplugin', requirePlugin), + playwright: await versionOf('@playwright/test', requireProject), + preload: preloadArg ?? null, + preloadSha256: preloadArg + ? createHash('sha256') + .update(await readFile(preloadArg)) + .digest('hex') + : null, + routerHead: execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repo, + encoding: 'utf8', + }).trim(), + } + return { + ...info, + directory, + versions, + inlineMaps, + virtualConfigPath: virtualConfigHelperDepth + ? `config/helper${virtualConfigHelperDepth}.ts` + : 'virtual-routes.ts', + logs: () => logs, + save, + tree: () => readFile(file(`routeTree.gen.${generatedExtension}`), 'utf8'), + rename: (from: string, to: string) => rename(file(from), file(to)), + remove: (name: string) => rm(file(name)), + assertAlive() { + if (child!.exitCode !== null || child!.signalCode !== null) { + throw new Error(`Bun exited\n${logs}`) + } + }, + async close() { + if (child!.exitCode === null && child!.signalCode === null) { + child!.kill() + let timer: ReturnType + await Promise.race([ + new Promise((resolve) => child!.once('exit', resolve)), + new Promise((resolve) => { + timer = setTimeout(resolve, 3_000) + }), + ]).finally(() => clearTimeout(timer!)) + if (child!.exitCode === null && child!.signalCode === null) { + child!.kill('SIGKILL') + await new Promise((resolve) => child!.once('exit', resolve)) + } + } + await rm(container, { recursive: true, force: true }) + }, + } + } catch (error) { + child?.kill('SIGKILL') + if (child && child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => child!.once('exit', resolve)) + } + await rm(container, { recursive: true, force: true }) + throw error + } +} diff --git a/e2e/react-router/bun-basic-file-based/dev-playwright.config.ts b/e2e/react-router/bun-basic-file-based/dev-playwright.config.ts new file mode 100644 index 00000000000..8680b8bf65d --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/dev-playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from '@playwright/test' + +if (!process.env.BUN_EXECUTABLE) { + throw new Error('Set BUN_EXECUTABLE to the native development candidate') +} + +export default defineConfig({ + testDir: '.', + testMatch: 'dev.test.ts', + workers: 1, + retries: 0, + timeout: 90_000, + expect: { timeout: 15_000 }, + reporter: [['line']], + outputDir: process.env.BUN_DEV_TEST_OUTPUT ?? './.cache/bun-dev-results', + use: { + ...devices['Desktop Chrome'], + trace: 'retain-on-failure', + launchOptions: { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + }, + }, +}) diff --git a/e2e/react-router/bun-basic-file-based/dev.test.ts b/e2e/react-router/bun-basic-file-based/dev.test.ts new file mode 100644 index 00000000000..105b495586b --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/dev.test.ts @@ -0,0 +1,712 @@ +import { expect } from '@playwright/test' +import { test as base } from '@tanstack/router-e2e-utils' +import { SourceMap } from 'node:module' +import type { SourceMapPayload } from 'node:module' +import type { CDPSession, Page } from '@playwright/test' +import { + aboutSource, + nestedSource, + startDevFixture, + virtualConfigSource, +} from './dev-fixture' + +const test = base.extend<{ + native: Awaited> + generatedExtension: string + virtualMount: boolean + virtualConfigFile: boolean + initialVirtualConfig: string | undefined + virtualConfigHelperDepth: 0 | 1 | 2 + autoCodeSplitting: boolean + projectAlias: boolean +}>({ + generatedExtension: ['ts', { option: true }], + virtualMount: [false, { option: true }], + virtualConfigFile: [false, { option: true }], + initialVirtualConfig: [undefined, { option: true }], + virtualConfigHelperDepth: [0, { option: true }], + autoCodeSplitting: [true, { option: true }], + projectAlias: [false, { option: true }], + native: async ( + { + generatedExtension, + virtualMount, + virtualConfigFile, + initialVirtualConfig, + virtualConfigHelperDepth, + autoCodeSplitting, + projectAlias, + }, + use, + testInfo, + ) => { + const native = await startDevFixture({ + generatedExtension, + virtualMount, + virtualConfigFile, + initialVirtualConfig, + virtualConfigHelperDepth, + autoCodeSplitting, + projectAlias, + }) + try { + await testInfo.attach('versions.json', { + body: JSON.stringify(native.versions, null, 2), + contentType: 'application/json', + }) + await use(native) + } finally { + await testInfo.attach('native.log', { + body: native.logs(), + contentType: 'text/plain', + }) + await native.close() + } + }, +}) + +test.describe('virtual physical directory outside routesDirectory', () => { + test.use({ virtualMount: true }) + test('discovers a route added inside an existing empty mounted directory', async ({ + page, + request, + native, + }, testInfo) => { + await page.goto(native.url) + await expect(page.getByRole('heading')).toHaveText('HOME_NATIVE_DEV') + const token = await documentToken(page) + await native.save( + 'mounted/empty/deep/created.tsx', + nestedSource('/empty/deep/created', 'MOUNTED_ADDED'), + ) + await expect.poll(() => native.tree()).toContain('/empty/deep/created') + await page + .getByRole('link', { name: 'Mounted created', exact: true }) + .click() + await expect(page.getByRole('heading')).toHaveText('MOUNTED_ADDED') + const sameDocumentAtCheckpoint = (await documentToken(page)) === token + native.assertAlive() + expect( + await ( + await request.get(new URL('/__native-info', native.url).href) + ).json(), + ).toEqual({ pid: native.pid }) + await testInfo.attach('mounted.json', { + body: JSON.stringify({ sameDocumentAtCheckpoint, sameProcess: true }), + contentType: 'application/json', + }) + }) +}) + +for (const extension of ['mts', 'cts', 'tsx']) { + test.describe(`generated route tree .${extension}`, () => { + test.use({ generatedExtension: extension }) + test('bundles the actual generated TypeScript through the public plugin', async ({ + page, + native, + }) => { + await page.goto(native.url) + await expect(page.getByRole('heading')).toHaveText('HOME_NATIVE_DEV') + expect(await native.tree()).toContain( + 'export interface FileRoutesByFullPath', + ) + await page.getByRole('link', { name: 'About', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('ABOUT_BEFORE') + native.assertAlive() + }) + }) +} + +const documentToken = (page: Page) => + page.evaluate(() => { + const state = globalThis as typeof globalThis & { + __bunDevDocumentToken?: string + } + return (state.__bunDevDocumentToken ??= crypto.randomUUID()) + }) + +test.describe('project directory alias', () => { + test.use({ projectAlias: true }) + test('splits canonical route modules and watches the aliased generated tree', async ({ + page, + request, + native, + }, testInfo) => { + await page.goto(native.url) + await expect(page.getByRole('heading')).toHaveText('HOME_NATIVE_DEV') + const initialToken = await documentToken(page) + await page.getByRole('link', { name: 'About', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('ABOUT_BEFORE') + expect(await documentToken(page)).toBe(initialToken) + const compiledRoute = () => + native.inlineMaps.find( + ({ contents }) => + contents.includes('AUTHORED_BEFORE') && + contents.includes('sourceMappingURL=data:'), + ) + await expect + .poll(compiledRoute, { + message: 'the canonical route receives the Router split transform', + }) + .toBeDefined() + const compiled = compiledRoute() + expectAuthoredPosition( + inlinePosition(compiled!.contents, 'AUTHORED_BEFORE'), + aboutSource(), + 'AUTHORED_BEFORE', + ) + await native.save( + 'routes/nested/empty/deep/added.tsx', + nestedSource('/nested/empty/deep/added', 'ALIASED_NESTED_ADDED'), + ) + await expect.poll(() => native.tree()).toContain('/nested/empty/deep/added') + await page.getByRole('link', { name: 'Nested added', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('ALIASED_NESTED_ADDED') + const checkpointToken = await documentToken(page) + await page.getByRole('link', { name: 'Home', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('HOME_NATIVE_DEV') + expect(await documentToken(page)).toBe(checkpointToken) + native.assertAlive() + expect( + await ( + await request.get(new URL('/__native-info', native.url).href) + ).json(), + ).toEqual({ pid: native.pid }) + await testInfo.attach('project-alias.json', { + body: JSON.stringify({ + sameProcess: true, + automaticDocumentReload: initialToken !== checkpointToken, + }), + contentType: 'application/json', + }) + }) +}) + +function inlinePosition(contents: string, marker: string) { + const match = contents.match( + /\/\/# sourceMappingURL=data:application\/json[^,]*;base64,([^\s]+)/, + ) + expect( + match, + 'Router must forward an inline compiler source map', + ).toBeTruthy() + const payload = JSON.parse( + Buffer.from(match![1]!, 'base64').toString(), + ) as SourceMapPayload + return mappedPosition(contents, marker, payload) +} + +function mappedPosition( + source: string, + marker: string, + payload: SourceMapPayload, +) { + const markerIndex = source.indexOf(marker) + expect(markerIndex).toBeGreaterThanOrEqual(0) + const errorIndex = source.lastIndexOf('new Error', markerIndex) + expect(errorIndex).toBeGreaterThanOrEqual(0) + const generated = source.slice(0, errorIndex).split('\n') + const entry = new SourceMap(payload).findEntry( + generated.length - 1, + generated.at(-1)!.length, + ) + if (!('originalSource' in entry)) { + throw new Error( + `No authored mapping for ${marker} at generated ${generated.length}:${generated.at(-1)!.length}`, + ) + } + return { entry, payload } +} + +function expectAuthoredPosition( + result: ReturnType, + authored: string, + marker: string, +) { + const { entry, payload } = result + expect(entry.originalSource?.replaceAll('\\', '/')).toMatch( + /\/routes\/about\.tsx$/, + ) + const authoredLines = authored.split('\n') + const line = authoredLines.findIndex((value) => value.includes(marker)) + expect(entry.originalLine).toBe(line) + expect(entry.originalColumn).toBe(authoredLines[line]!.indexOf('new Error')) + const index = payload.sources.indexOf(entry.originalSource!) + expect(payload.sourcesContent?.[index]).toBe(authored) +} + +for (const autoCodeSplitting of [true, false]) { + test.describe(`autoCodeSplitting: ${autoCodeSplitting}`, () => { + test.use({ autoCodeSplitting }) + test('native lazy navigation and component/loader edits preserve the document and state', async ({ + page, + request, + native, + }, testInfo) => { + const errors: Array = [] + page.on('pageerror', (error) => errors.push(error.message)) + await page.goto(native.url) + await expect(page.getByRole('heading')).toHaveText('HOME_NATIVE_DEV') + await expect(page.getByText('ABOUT_BEFORE', { exact: true })).toHaveCount( + 0, + ) + await page.getByRole('link', { name: 'About', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('ABOUT_BEFORE') + await expect(page.getByRole('heading')).toHaveAttribute( + 'data-shared-identity', + 'true', + ) + expect( + await page.evaluate( + () => + ( + globalThis as typeof globalThis & { + __bunDevSharedInitializations?: number + } + ).__bunDevSharedInitializations, + ), + ).toBe(1) + expect( + native.inlineMaps.some((event) => + event.id.includes('?tsr-split=component'), + ), + ).toBe(autoCodeSplitting) + await page.getByRole('button', { name: 'Count 0', exact: true }).click() + await expect( + page.getByRole('button', { name: 'Count 1', exact: true }), + ).toBeVisible() + const token = await documentToken(page) + + await native.save('routes/about.tsx', aboutSource('ABOUT_AFTER')) + await expect(page.getByRole('heading')).toHaveText('ABOUT_AFTER') + await expect( + page.getByRole('button', { name: 'Count 1', exact: true }), + ).toBeVisible() + expect(await documentToken(page)).toBe(token) + await expect(page.getByRole('heading')).toHaveAttribute( + 'data-shared-identity', + 'true', + ) + + await native.save( + 'routes/about.tsx', + aboutSource('ABOUT_AFTER', 'LOADER_AFTER'), + ) + await expect(page.getByTestId('loader')).toHaveText('LOADER_AFTER') + await expect( + page.getByRole('button', { name: 'Count 1', exact: true }), + ).toBeVisible() + await expect(page.getByRole('heading')).toHaveAttribute( + 'data-shared-identity', + 'true', + ) + expect(await documentToken(page)).toBe(token) + native.assertAlive() + expect( + await ( + await request.get(new URL('/__native-info', native.url).href) + ).json(), + ).toEqual({ pid: native.pid }) + await testInfo.attach('lifecycle.json', { + body: JSON.stringify({ sameDocument: true, sameProcess: true, errors }), + contentType: 'application/json', + }) + expect(errors).toEqual([]) + }) + }) +} + +test('inline compiler maps and browser mappings retain authored positions before and after HMR', async ({ + page, + request, + native, +}, testInfo) => { + const session: CDPSession = await page.context().newCDPSession(page) + const scripts: Array<{ + scriptId: string + url: string + sourceMapURL: string + }> = [] + session.on('Debugger.scriptParsed', (event) => { + scripts.push({ + scriptId: event.scriptId, + url: event.url, + sourceMapURL: event.sourceMapURL ?? '', + }) + }) + await session.send('Debugger.enable') + try { + await page.goto(new URL('/about', native.url).href) + await expect(page.getByRole('heading')).toHaveText('ABOUT_BEFORE') + const token = await documentToken(page) + for (const [heading, marker, authored] of [ + ['ABOUT_BEFORE', 'AUTHORED_BEFORE', aboutSource()], + [ + 'ABOUT_MAPPED_AFTER', + 'AUTHORED_AFTER', + aboutSource('ABOUT_MAPPED_AFTER', 'LOADER_BEFORE', 'AUTHORED_AFTER'), + ], + ]) { + const start = heading === 'ABOUT_BEFORE' ? 0 : native.inlineMaps.length + if (heading !== 'ABOUT_BEFORE') { + await native.save('routes/about.tsx', authored!) + } + await expect(page.getByRole('heading')).toHaveText(heading!) + await expect + .poll(() => + native.inlineMaps + .slice(start) + .some( + (event) => + event.id.includes('?tsr-split=component') && + event.contents.includes(marker!), + ), + ) + .toBe(true) + const event = native.inlineMaps + .slice(start) + .findLast( + (value) => + value.id.includes('?tsr-split=component') && + value.contents.includes(marker!), + )! + expectAuthoredPosition( + inlinePosition(event.contents, marker!), + authored!, + marker!, + ) + + let browserResult: ReturnType | undefined + let browserMapKind = '' + await expect + .poll(async () => { + for (const script of [...scripts].reverse()) { + if (!script.sourceMapURL) { + continue + } + const { scriptSource } = await session.send( + 'Debugger.getScriptSource', + { scriptId: script.scriptId }, + ) + if (!scriptSource.includes(marker!)) { + continue + } + let payload: SourceMapPayload + if (script.sourceMapURL.startsWith('data:')) { + const [header, data] = script.sourceMapURL.split(',', 2) + payload = JSON.parse( + header!.includes(';base64') + ? Buffer.from(data!, 'base64').toString() + : decodeURIComponent(data!), + ) + browserMapKind = 'inline' + } else { + const scriptUrl = new URL(script.url || native.url, native.url) + // Chromium resolves a blob script's map against its inner URL. + const mapBase = + scriptUrl.protocol === 'blob:' + ? scriptUrl.pathname + : scriptUrl.href + const response = await request.get( + new URL(script.sourceMapURL, mapBase).href, + ) + if (!response.ok()) { + continue + } + payload = await response.json() + browserMapKind = 'external' + } + browserResult = mappedPosition(scriptSource, marker!, payload) + return true + } + return false + }) + .toBe(true) + expectAuthoredPosition(browserResult!, authored!, marker!) + expect(await documentToken(page)).toBe(token) + await testInfo.attach(`${marker}-mapping.json`, { + body: JSON.stringify({ + input: inlinePosition(event.contents, marker!).entry, + browser: browserResult!.entry, + browserMapKind, + }), + contentType: 'application/json', + }) + } + native.assertAlive() + } finally { + await session.detach() + } +}) + +test('discovers nested empty directories and handles add, rename and delete without a process restart', async ({ + page, + request, + native, +}, testInfo) => { + await page.goto(native.url) + await expect(page.getByRole('heading')).toHaveText('HOME_NATIVE_DEV') + const topology: Array<{ stage: string; automaticDocumentReload: boolean }> = + [] + let token = await documentToken(page) + await native.save( + 'routes/nested/empty/deep/added.tsx', + nestedSource('/nested/empty/deep/added', 'NESTED_ADDED'), + ) + await expect.poll(() => native.tree()).toContain('/nested/empty/deep/added') + await page.getByRole('link', { name: 'Nested added', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('NESTED_ADDED') + topology.push({ + stage: 'add-in-existing-empty-nested-directory', + automaticDocumentReload: (await documentToken(page)) !== token, + }) + await page.getByRole('link', { name: 'Home', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('HOME_NATIVE_DEV') + token = await documentToken(page) + + await native.rename( + 'routes/nested/empty/deep/added.tsx', + 'routes/nested/empty/deep/renamed.tsx', + ) + await expect + .poll(async () => { + const tree = await native.tree() + return ( + tree.includes('/nested/empty/deep/renamed') && + !tree.includes('/nested/empty/deep/added') + ) + }) + .toBe(true) + await page.getByRole('link', { name: 'Nested renamed', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('NESTED_ADDED') + await page.getByRole('link', { name: 'Nested added', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('NOT_FOUND') + topology.push({ + stage: 'rename', + automaticDocumentReload: (await documentToken(page)) !== token, + }) + await page.getByRole('link', { name: 'Home', exact: true }).click() + token = await documentToken(page) + + await native.remove('routes/nested/empty/deep/renamed.tsx') + await expect + .poll( + async () => !(await native.tree()).includes('/nested/empty/deep/renamed'), + ) + .toBe(true) + await page.getByRole('link', { name: 'Nested renamed', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('NOT_FOUND') + topology.push({ + stage: 'delete', + automaticDocumentReload: (await documentToken(page)) !== token, + }) + native.assertAlive() + expect( + await ( + await request.get(new URL('/__native-info', native.url).href) + ).json(), + ).toEqual({ pid: native.pid }) + await testInfo.attach('topology.json', { + body: JSON.stringify({ pid: native.pid, topology }, null, 2), + contentType: 'application/json', + }) +}) + +test('repairs a route generation conflict without restarting the server', async ({ + page, + request, + native, + whitelistErrors, +}, testInfo) => { + whitelistErrors.push(/conflicting configuration paths/i) + await page.goto(native.url) + await expect(page.getByRole('heading')).toHaveText('HOME_NATIVE_DEV') + const token = await documentToken(page) + const errorStart = native.logs().length + await native.save( + 'routes/(conflict)/about.tsx', + nestedSource('/(conflict)/about', 'CONFLICTING_ABOUT'), + ) + await expect + .poll(() => native.logs().slice(errorStart)) + .toMatch(/conflicting configuration paths/i) + native.assertAlive() + + await native.remove('routes/(conflict)/about.tsx') + await native.save( + 'routes/repaired.tsx', + nestedSource('/repaired', 'GENERATION_REPAIRED'), + ) + await expect.poll(() => native.tree()).toContain('/repaired') + await page.getByRole('link', { name: 'Repaired', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('GENERATION_REPAIRED') + native.assertAlive() + expect( + await ( + await request.get(new URL('/__native-info', native.url).href) + ).json(), + ).toEqual({ pid: native.pid }) + await testInfo.attach('generation-recovery.json', { + body: JSON.stringify({ + sameProcess: true, + automaticDocumentReload: (await documentToken(page)) !== token, + }), + contentType: 'application/json', + }) +}) + +test('repairs a syntax error in an active route without restarting the server', async ({ + page, + request, + native, + whitelistErrors, +}, testInfo) => { + whitelistErrors.push( + /Unexpected.*;|Expected (?:expression|identifier)|SyntaxError|SYNTAX_BROKEN/, + ) + await page.goto(native.url) + await page.getByRole('link', { name: 'About', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('ABOUT_BEFORE') + const token = await documentToken(page) + const errorStart = native.logs().length + await native.save( + 'routes/about.tsx', + 'export const Route = ; // SYNTAX_BROKEN\n', + ) + await expect + .poll(() => { + const output = native.logs().slice(errorStart) + return ( + output.includes('about.tsx') && + /Unexpected|Expected (?:expression|identifier)|SyntaxError/.test(output) + ) + }) + .toBe(true) + native.assertAlive() + + await native.save( + 'routes/about.tsx', + aboutSource('SYNTAX_REPAIRED', 'LOADER_REPAIRED'), + ) + await expect(page.getByRole('heading')).toHaveText('SYNTAX_REPAIRED') + await expect(page.getByTestId('loader')).toHaveText('LOADER_REPAIRED') + native.assertAlive() + expect( + await ( + await request.get(new URL('/__native-info', native.url).href) + ).json(), + ).toEqual({ pid: native.pid }) + await testInfo.attach('syntax-recovery.json', { + body: JSON.stringify({ + sameProcess: true, + automaticDocumentReload: (await documentToken(page)) !== token, + }), + contentType: 'application/json', + }) +}) + +for (const [failure, source] of [ + ['syntax', 'export default {'], + ['schema', "export default { type: 'root', file: 42 }"], +] as const) { + test.describe(`initial virtual configuration ${failure} error`, () => { + test.use({ virtualConfigFile: true, initialVirtualConfig: source }) + test('recovers on a new request without restarting Bun', async ({ + page, + request, + native, + }) => { + expect((await request.get(native.url)).status()).toBe(500) + await native.save('virtual-routes.ts', virtualConfigSource('after')) + await page.goto(native.url) + await expect(page.getByRole('heading')).toHaveText('HOME_NATIVE_DEV') + await page + .getByRole('link', { name: 'Virtual after', exact: true }) + .click() + await expect(page.getByRole('heading')).toHaveText('VIRTUAL_AFTER') + native.assertAlive() + expect( + await ( + await request.get(new URL('/__native-info', native.url).href) + ).json(), + ).toEqual({ pid: native.pid }) + }) + }) +} + +for (const virtualConfigHelperDepth of [0, 1, 2] as const) { + test.describe(`string virtual configuration with ${virtualConfigHelperDepth} helper imports`, () => { + test.use({ virtualConfigFile: true, virtualConfigHelperDepth }) + test('updates existing browser navigation after config edits and atomic replacement', async ({ + page, + request, + native, + }, testInfo) => { + await page.goto(native.url) + await page + .getByRole('link', { name: 'Virtual before', exact: true }) + .click() + await expect(page.getByRole('heading')).toHaveText('VIRTUAL_BEFORE') + await page.getByRole('link', { name: 'Home', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('HOME_NATIVE_DEV') + const token = await documentToken(page) + + for (const [included, atomic] of [ + ['after', false], + ['before', false], + ['after', true], + ] as const) { + if (atomic) { + await native.save( + `${native.virtualConfigPath}.next`, + virtualConfigSource(included), + ) + await native.rename( + `${native.virtualConfigPath}.next`, + native.virtualConfigPath, + ) + } else { + await native.save( + native.virtualConfigPath, + virtualConfigSource(included), + ) + } + const excluded = included === 'after' ? 'before' : 'after' + await expect + .poll(async () => { + const tree = await native.tree() + return ( + tree.includes(`/virtual-${included}`) && + !tree.includes(`/virtual-${excluded}`) + ) + }) + .toBe(true) + await page + .getByRole('link', { name: `Virtual ${included}`, exact: true }) + .click() + await expect(page.getByRole('heading')).toHaveText( + `VIRTUAL_${included.toUpperCase()}`, + ) + await page + .getByRole('link', { name: `Virtual ${excluded}`, exact: true }) + .click() + await expect(page.getByRole('heading')).toHaveText('NOT_FOUND') + await page.getByRole('link', { name: 'Home', exact: true }).click() + await expect(page.getByRole('heading')).toHaveText('HOME_NATIVE_DEV') + } + native.assertAlive() + expect( + await ( + await request.get(new URL('/__native-info', native.url).href) + ).json(), + ).toEqual({ pid: native.pid }) + await testInfo.attach('virtual-config.json', { + body: JSON.stringify({ + sameProcess: true, + automaticDocumentReload: (await documentToken(page)) !== token, + }), + contentType: 'application/json', + }) + }) + }) +} diff --git a/e2e/react-router/bun-basic-file-based/fixture/main.tsx b/e2e/react-router/bun-basic-file-based/fixture/main.tsx new file mode 100644 index 00000000000..94cd7e9c789 --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/fixture/main.tsx @@ -0,0 +1,9 @@ +import { createRoot } from 'react-dom/client' +import { createRouter, RouterProvider } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +const router = createRouter({ routeTree, defaultPreload: false }) + +createRoot(document.getElementById('app')!).render( + , +) diff --git a/e2e/react-router/bun-basic-file-based/fixture/routes/__root.tsx b/e2e/react-router/bun-basic-file-based/fixture/routes/__root.tsx new file mode 100644 index 00000000000..18b45b14bb5 --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/fixture/routes/__root.tsx @@ -0,0 +1,14 @@ +import { createRootRoute, Link, Outlet } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: () => ( + <> + + + + ), +}) diff --git a/e2e/react-router/bun-basic-file-based/fixture/routes/about.tsx b/e2e/react-router/bun-basic-file-based/fixture/routes/about.tsx new file mode 100644 index 00000000000..1729aeb4c5c --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/fixture/routes/about.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router' + +const state = globalThis as typeof globalThis & { + __bunSharedInitializations?: number +} + +const shared = { + initialization: (state.__bunSharedInitializations = + (state.__bunSharedInitializations ?? 0) + 1), +} + +export const Route = createFileRoute('/about')({ + beforeLoad: () => shared, + loader: () => shared, + component: About, +}) + +function About() { + const data = Route.useLoaderData() + return ( +

+ ABOUT_NATIVE_BUN {shared.initialization} +

+ ) +} diff --git a/e2e/react-router/bun-basic-file-based/fixture/routes/details.lazy.tsx b/e2e/react-router/bun-basic-file-based/fixture/routes/details.lazy.tsx new file mode 100644 index 00000000000..92d513e7258 --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/fixture/routes/details.lazy.tsx @@ -0,0 +1,5 @@ +import { createLazyFileRoute } from '@tanstack/react-router' + +export const Route = createLazyFileRoute('/details')({ + component: () =>

DETAILS_DYNAMIC_BUN

, +}) diff --git a/e2e/react-router/bun-basic-file-based/fixture/routes/details.tsx b/e2e/react-router/bun-basic-file-based/fixture/routes/details.tsx new file mode 100644 index 00000000000..8c29d2f4f6a --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/fixture/routes/details.tsx @@ -0,0 +1,3 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/details')({}) diff --git a/e2e/react-router/bun-basic-file-based/fixture/routes/index.tsx b/e2e/react-router/bun-basic-file-based/fixture/routes/index.tsx new file mode 100644 index 00000000000..8a31d996402 --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/fixture/routes/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: () =>

HOME_NATIVE_BUN

, +}) diff --git a/e2e/react-router/bun-basic-file-based/package.json b/e2e/react-router/bun-basic-file-based/package.json new file mode 100644 index 00000000000..c2e82634643 --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/package.json @@ -0,0 +1,50 @@ +{ + "name": "tanstack-router-e2e-react-bun-basic-file-based", + "private": true, + "type": "module", + "scripts": { + "test:e2e:bun": "playwright test --config playwright.config.ts", + "test:e2e:bun:dev": "node run-dev.mjs" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/router-plugin": "workspace:^", + "@tanstack/virtual-file-routes": "workspace:^", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@playwright/test": "catalog:", + "@tanstack/router-e2e-utils": "workspace:^" + }, + "nx": { + "targets": { + "test:e2e:bun": { + "dependsOn": [ + "^build" + ], + "cache": true, + "inputs": [ + "default", + "^production", + "dependentTaskOutputs", + { + "runtime": "bun --version" + } + ], + "outputs": [ + "{projectRoot}/.cache/bun-e2e", + "{projectRoot}/test-results" + ], + "parallelism": false + }, + "test:e2e:bun:dev": { + "dependsOn": [ + "^build" + ], + "cache": false, + "parallelism": false + } + } + } +} diff --git a/e2e/react-router/bun-basic-file-based/playwright.config.ts b/e2e/react-router/bun-basic-file-based/playwright.config.ts new file mode 100644 index 00000000000..34cb7553a71 --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test' +import { appServerReady } from '@tanstack/router-e2e-utils' + +const port = Number(process.env.E2E_APP_PORT ?? 0) + +export default defineConfig({ + testDir: '.', + testMatch: '**/*.spec.ts', + workers: 1, + reporter: [['line']], + use: { + baseURL: `http://127.0.0.1:${port}`, + ...devices['Desktop Chrome'], + launchOptions: { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + }, + }, + webServer: { + command: 'bun build.mjs --serve', + env: { E2E_APP_PORT: String(port) }, + wait: appServerReady, + reuseExistingServer: false, + stdout: 'pipe', + }, +}) diff --git a/e2e/react-router/bun-basic-file-based/run-dev.mjs b/e2e/react-router/bun-basic-file-based/run-dev.mjs new file mode 100644 index 00000000000..027098721dd --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/run-dev.mjs @@ -0,0 +1,57 @@ +import { createRequire } from 'node:module' +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { existsSync } from 'node:fs' + +if (!process.env.BUN_EXECUTABLE) { + throw new Error('Set BUN_EXECUTABLE to the native development candidate') +} +let repo = path.dirname(fileURLToPath(import.meta.url)) +while (!existsSync(path.join(repo, 'packages/router-plugin/package.json'))) { + const parent = path.dirname(repo) + if (parent === repo) { + throw new Error('Run this harness inside the Router repository') + } + repo = parent +} +const project = path.dirname(fileURLToPath(import.meta.url)) +const requireProject = createRequire(path.join(project, 'package.json')) +const config = fileURLToPath( + new URL('./dev-playwright.config.ts', import.meta.url), +) +const executable = process.env.BUN_EXECUTABLE +const preload = process.env.BUN_PLUGIN_PRELOAD +const child = spawn( + process.execPath, + [ + requireProject.resolve('@playwright/test/cli'), + 'test', + '--config', + config, + ...process.argv.slice(2), + ], + { + cwd: project, + env: { + ...process.env, + ROUTER_REPOSITORY_ROOT: repo, + BUN_EXECUTABLE: /[\\/]/.test(executable) + ? path.resolve(executable) + : executable, + BUN_PLUGIN_PRELOAD: + preload && !preload.startsWith('file:') + ? path.resolve(preload) + : preload, + }, + stdio: 'inherit', + windowsHide: true, + }, +) +child.on('error', (error) => { + console.error(error) + process.exitCode = 1 +}) +child.on('exit', (code, signal) => { + process.exitCode = signal ? 1 : (code ?? 1) +}) diff --git a/e2e/react-router/bun-basic-file-based/source-maps.mjs b/e2e/react-router/bun-basic-file-based/source-maps.mjs new file mode 100644 index 00000000000..601438a78d8 --- /dev/null +++ b/e2e/react-router/bun-basic-file-based/source-maps.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict' +import { cp, mkdir, readFile, rm } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import tanstackRouter from '@tanstack/router-plugin/bun' + +export async function verifyBunRouteSourceMaps() { + const here = path.dirname(fileURLToPath(import.meta.url)) + const workspace = path.resolve(here, './.cache/bun-source-maps') + const fixture = path.join(workspace, 'fixture # %') + await rm(workspace, { recursive: true, force: true }) + await mkdir(workspace, { recursive: true }) + await cp(path.join(here, 'fixture'), fixture, { recursive: true }) + + const authoredSource = + '\n\nexport const inputMapProbe = "authored input map"\n' + const inputMap = Buffer.from( + JSON.stringify({ + version: 3, + sources: [pathToFileURL(path.join(workspace, 'authored-probe.js')).href], + sourcesContent: [authoredSource], + names: [], + mappings: 'AAEA', + }), + ).toString('base64') + const probe = await Bun.build({ + entrypoints: ['router-input-map-probe'], + outdir: path.join(workspace, 'probe'), + sourcemap: 'external', + plugins: [ + { + name: 'input-source-map-capability', + setup(build) { + build.onResolve({ filter: /^router-input-map-probe$/ }, () => ({ + path: 'probe.js', + namespace: 'input-map-probe', + })) + build.onLoad({ filter: /.*/, namespace: 'input-map-probe' }, () => ({ + contents: `export const inputMapProbe = "generated input map"\n//# sourceMappingURL=data:application/json;base64,${inputMap}`, + loader: 'js', + })) + }, + }, + ], + }) + assert.equal(probe.success, true, probe.logs.map(String).join('\n')) + const probeMaps = await Promise.all( + probe.outputs + .filter((output) => output.path.endsWith('.map')) + .map(async (output) => JSON.parse(await output.text())), + ) + assert.ok(probeMaps.length > 0) + const supportsInputSourceMaps = probeMaps.some((map) => + map.sourcesContent.includes(authoredSource), + ) + + for (const sourcemap of [ + 'external', + 'inline', + 'linked', + undefined, + false, + 'none', + ]) { + const loaded = [] + const router = tanstackRouter({ + target: 'react', + routesDirectory: path.join(fixture, 'routes'), + generatedRouteTree: path.join(fixture, 'routeTree.gen.ts'), + tmpDir: path.join(workspace, 'tmp'), + autoCodeSplitting: true, + disableLogging: true, + codeSplittingOptions: { defaultBehavior: [['loader'], ['component']] }, + }) + const result = await Bun.build({ + entrypoints: [path.join(fixture, 'main.tsx')], + outdir: path.join(workspace, String(sourcemap)), + target: 'browser', + format: 'esm', + splitting: true, + sourcemap, + define: { 'process.env.NODE_ENV': '"production"' }, + plugins: [ + { + name: 'observe-router-source-maps', + setup(build) { + return router.setup({ + ...build, + onLoad(options, callback) { + build.onLoad(options, async (args) => { + const result = await callback(args) + if ( + /^tanstack-router-tsr-(split|shared)$/.test(args.namespace) + ) { + loaded.push({ id: args.path, contents: result.contents }) + } + return result + }) + }, + }) + }, + }, + ], + }) + assert.equal(result.success, true, result.logs.map(String).join('\n')) + for (const parameter of ['tsr-split=', 'tsr-shared=']) { + assert.ok(loaded.some(({ id }) => id.includes(parameter))) + } + for (const { id, contents } of loaded) { + const inlineMap = contents.match( + /\/\/# sourceMappingURL=data:application\/json[^,]*;base64,([^\s]+)/, + ) + if (!sourcemap || sourcemap === 'none') { + assert.equal(inlineMap, null, `${id}: maps are disabled`) + continue + } + assert.ok(inlineMap, `${id}: forwards the compiler source map`) + const map = JSON.parse(Buffer.from(inlineMap[1], 'base64').toString()) + const filename = id.split('?')[0] + assert.deepEqual(map.sources, [pathToFileURL(filename).href]) + assert.deepEqual(map.sourcesContent, [await readFile(filename, 'utf8')]) + assert.ok(map.mappings.length > 0, `${id}: preserves compiler mappings`) + } + } + return supportsInputSourceMaps +} diff --git a/packages/router-generator/src/filesystem/physical/getRouteNodes.ts b/packages/router-generator/src/filesystem/physical/getRouteNodes.ts index 09e7fc6c25e..52280810abc 100644 --- a/packages/router-generator/src/filesystem/physical/getRouteNodes.ts +++ b/packages/router-generator/src/filesystem/physical/getRouteNodes.ts @@ -62,6 +62,7 @@ export async function getRouteNodes( const routeNodes: Array = [] const allPhysicalDirectories: Array = [] + const allConfigFilePaths: Array = [] async function recurse(dir: string) { const fullDir = path.resolve(config.routesDirectory, dir) @@ -98,7 +99,7 @@ export async function getRouteNodes( }) if (virtualConfigFile !== undefined) { - const virtualRouteConfigExport = await loadConfigFile( + const { exports: virtualRouteConfigExport, cache } = await loadConfigFile( path.resolve(fullDir, virtualConfigFile.name), ) let virtualRouteSubtreeConfig: VirtualRouteSubtreeConfig @@ -107,22 +108,27 @@ export async function getRouteNodes( } else { virtualRouteSubtreeConfig = virtualRouteConfigExport.default } + allConfigFilePaths.push(...Object.keys(cache)) const dummyRoot: VirtualRootRoute = { type: 'root', file: '', children: virtualRouteSubtreeConfig, } - const { routeNodes: virtualRouteNodes, physicalDirectories } = - await getRouteNodesVirtual( - { - ...config, - routesDirectory: fullDir, - virtualRouteConfig: dummyRoot, - }, - root, - tokenRegexes, - ) + const { + routeNodes: virtualRouteNodes, + physicalDirectories, + configFilePaths, + } = await getRouteNodesVirtual( + { + ...config, + routesDirectory: fullDir, + virtualRouteConfig: dummyRoot, + }, + root, + tokenRegexes, + ) allPhysicalDirectories.push(...physicalDirectories) + allConfigFilePaths.push(...(configFilePaths ?? [])) virtualRouteNodes.forEach((node) => { const normalizedDir = dir === './' ? '' : dir const filePath = replaceBackslash( @@ -379,6 +385,7 @@ export async function getRouteNodes( rootRouteNode, routeNodes, physicalDirectories: allPhysicalDirectories, + configFilePaths: allConfigFilePaths, } } diff --git a/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts b/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts index 5d3cd610be8..2d0735a1637 100644 --- a/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts +++ b/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts @@ -61,15 +61,19 @@ export async function getRouteNodes( throw new Error(`virtualRouteConfig is undefined`) } let virtualRouteConfig: VirtualRootRoute + const configFilePaths: Array = [] if (typeof tsrConfig.virtualRouteConfig === 'string') { - virtualRouteConfig = await getVirtualRouteConfigFromFileExport( - tsrConfig, - root, - ) + const loaded = await getVirtualRouteConfigFromFileExport(tsrConfig, root) + virtualRouteConfig = loaded.config + configFilePaths.push(...loaded.configFilePaths) } else { virtualRouteConfig = tsrConfig.virtualRouteConfig } - const { children, physicalDirectories } = await getRouteNodesRecursive( + const { + children, + physicalDirectories, + configFilePaths: nestedConfigFilePaths, + } = await getRouteNodesRecursive( tsrConfig, root, fullDir, @@ -88,7 +92,12 @@ export async function getRouteNodes( const rootRouteNode = allNodes[0] const routeNodes = allNodes.slice(1) - return { rootRouteNode, routeNodes, physicalDirectories } + return { + rootRouteNode, + routeNodes, + physicalDirectories, + configFilePaths: [...configFilePaths, ...nestedConfigFilePaths], + } } /** @@ -108,7 +117,7 @@ export async function getRouteNodes( async function getVirtualRouteConfigFromFileExport( tsrConfig: Pick, root: string, -): Promise { +): Promise<{ config: VirtualRootRoute; configFilePaths: Array }> { if ( tsrConfig.virtualRouteConfig === undefined || typeof tsrConfig.virtualRouteConfig !== 'string' || @@ -116,7 +125,9 @@ async function getVirtualRouteConfigFromFileExport( ) { throw new Error(`virtualRouteConfig is undefined or empty`) } - const exports = await loadConfigFile(join(root, tsrConfig.virtualRouteConfig)) + const { exports, cache } = await loadConfigFile( + resolve(root, tsrConfig.virtualRouteConfig), + ) if (!('routes' in exports) && !('default' in exports)) { throw new Error( @@ -127,7 +138,10 @@ async function getVirtualRouteConfigFromFileExport( const virtualRouteConfig = 'routes' in exports ? exports.routes : exports.default - return virtualRootRouteSchema.parse(virtualRouteConfig) + return { + config: virtualRootRouteSchema.parse(virtualRouteConfig), + configFilePaths: Object.keys(cache), + } } export async function getRouteNodesRecursive( @@ -144,11 +158,16 @@ export async function getRouteNodesRecursive( nodes: Array | undefined, tokenRegexes: TokenRegexBundle, parent?: RouteNode, -): Promise<{ children: Array; physicalDirectories: Array }> { +): Promise<{ + children: Array + physicalDirectories: Array + configFilePaths: Array +}> { if (nodes === undefined) { - return { children: [], physicalDirectories: [] } + return { children: [], physicalDirectories: [], configFilePaths: [] } } const allPhysicalDirectories: Array = [] + const allConfigFilePaths: Array = [] const children = await Promise.all( nodes.map(async (node) => { if (node.type === 'physical') { @@ -160,18 +179,20 @@ export async function getRouteNodesRecursive( removeLeadingSlash(node.pathPrefix), ) : { routePath: '', originalRoutePath: '' } - const { routeNodes, physicalDirectories } = await getRouteNodesPhysical( - { - ...tsrConfig, - routesDirectory: resolve(fullDir, node.directory), - }, - root, - tokenRegexes, - ) + const { routeNodes, physicalDirectories, configFilePaths } = + await getRouteNodesPhysical( + { + ...tsrConfig, + routesDirectory: resolve(fullDir, node.directory), + }, + root, + tokenRegexes, + ) allPhysicalDirectories.push( resolve(fullDir, node.directory), ...physicalDirectories, ) + allConfigFilePaths.push(...(configFilePaths ?? [])) routeNodes.forEach((subtreeNode) => { const pathPrefix = cleanPath( `${parent?.routePath ?? ''}${routePathPrefix}`, @@ -277,7 +298,7 @@ export async function getRouteNodesRecursive( } if (node.children !== undefined) { - const { children, physicalDirectories } = + const { children, physicalDirectories, configFilePaths } = await getRouteNodesRecursive( tsrConfig, root, @@ -288,6 +309,7 @@ export async function getRouteNodesRecursive( ) routeNode.children = children allPhysicalDirectories.push(...physicalDirectories) + allConfigFilePaths.push(...configFilePaths) // If the route has children, it should be a layout routeNode._fsRouteType = 'layout' @@ -334,7 +356,7 @@ export async function getRouteNodesRecursive( } if (node.children !== undefined) { - const { children, physicalDirectories } = + const { children, physicalDirectories, configFilePaths } = await getRouteNodesRecursive( tsrConfig, root, @@ -345,6 +367,7 @@ export async function getRouteNodesRecursive( ) routeNode.children = children allPhysicalDirectories.push(...physicalDirectories) + allConfigFilePaths.push(...configFilePaths) } return routeNode } @@ -354,5 +377,6 @@ export async function getRouteNodesRecursive( return { children: children.flat(), physicalDirectories: allPhysicalDirectories, + configFilePaths: allConfigFilePaths, } } diff --git a/packages/router-generator/src/filesystem/virtual/loadConfigFile.ts b/packages/router-generator/src/filesystem/virtual/loadConfigFile.ts index a4606e4e193..c0a8946e0e1 100644 --- a/packages/router-generator/src/filesystem/virtual/loadConfigFile.ts +++ b/packages/router-generator/src/filesystem/virtual/loadConfigFile.ts @@ -1,10 +1,24 @@ +import { readFile } from 'node:fs/promises' import { createJiti } from 'jiti' +import { replaceBackslash } from '../../utils' +import type { ModuleCache } from 'jiti' export async function loadConfigFile(filePath: string) { + // Match Jiti's resolved import paths so cycles share the entry module cache. + filePath = replaceBackslash(filePath) const jiti = createJiti(filePath, { interopDefault: false, + moduleCache: false, + tryNative: false, tsconfigPaths: true, }) - const loaded = await jiti.import(filePath) - return loaded + // Keep the evaluated import graph local to this generation for file watching. + const cache: ModuleCache = {} + const source = await readFile(filePath, 'utf8') + const loaded = await jiti.evalModule(source, { + filename: filePath, + async: true, + cache, + }) + return { exports: loaded as any, cache } } diff --git a/packages/router-generator/src/generator.ts b/packages/router-generator/src/generator.ts index 91f87dadda2..363590b168c 100644 --- a/packages/router-generator/src/generator.ts +++ b/packages/router-generator/src/generator.ts @@ -205,6 +205,7 @@ export class Generator { private routeTreeFileCache: GeneratorCacheEntry | undefined private crawlingResult: CrawlingResult | undefined + private configFilePaths = new Set() public config: Config public targetTemplate: TargetTemplate @@ -304,6 +305,29 @@ export class Generator { ) } + public getWatchDependencies(): { + files: Array + directories: Array + } { + const files = new Set(this.configFilePaths) + if (typeof this.config.virtualRouteConfig === 'string') { + files.add(path.resolve(this.root, this.config.virtualRouteConfig)) + } + return { + files: [...files], + directories: [ + ...new Set( + [ + ...(this.config.virtualRouteConfig + ? [] + : [this.routesDirectoryPath]), + ...this.physicalDirectories, + ].map((directory) => path.resolve(this.root, directory)), + ), + ], + } + } + public async run(event?: GeneratorEvent): Promise { if ( event && @@ -407,6 +431,7 @@ export class Generator { rootRouteNode, routeNodes: beforeRouteNodes, physicalDirectories, + configFilePaths, } = getRouteNodesResult if (rootRouteNode === undefined) { let errorMessage = `rootRouteNode must not be undefined. Make sure you've added your root route into the route-tree.` @@ -416,6 +441,9 @@ export class Generator { throw new Error(errorMessage) } this.physicalDirectories = physicalDirectories + this.configFilePaths = new Set( + configFilePaths?.map((filePath) => path.resolve(filePath)), + ) await this.handleRootNode(rootRouteNode) @@ -1635,10 +1663,13 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved return true } - // the virtual route config file passed into `virtualRouteConfig` + // Virtual configuration modules and the helpers loaded while evaluating them. + const resolvedFilePath = path.resolve(filePath) if ( - typeof this.config.virtualRouteConfig === 'string' && - filePath === this.config.virtualRouteConfig + this.configFilePaths.has(resolvedFilePath) || + (typeof this.config.virtualRouteConfig === 'string' && + resolvedFilePath === + path.resolve(this.root, this.config.virtualRouteConfig)) ) { return true } diff --git a/packages/router-generator/src/types.ts b/packages/router-generator/src/types.ts index ec0aa226ceb..d003a6a5bf3 100644 --- a/packages/router-generator/src/types.ts +++ b/packages/router-generator/src/types.ts @@ -33,6 +33,7 @@ export interface GetRouteNodesResult { rootRouteNode?: RouteNode routeNodes: Array physicalDirectories: Array + configFilePaths?: Array } export type FsRouteType = diff --git a/packages/router-generator/tests/generator.test.ts b/packages/router-generator/tests/generator.test.ts index 3e0831fe91c..7d93fd246e2 100644 --- a/packages/router-generator/tests/generator.test.ts +++ b/packages/router-generator/tests/generator.test.ts @@ -408,6 +408,38 @@ describe('generator works', async () => { }) }) + it.each([ + { folder: 'only-root', directories: ['routes'] }, + { + folder: 'virtual-physical-no-prefix', + directories: ['routes/merged'], + }, + ])( + 'returns isolated resolved directory roots for $folder', + async ({ folder, directories }) => { + const root = makeFolderDir(folder) + const config = setupConfig(folder) + const generator = new Generator({ config, root }) + + await generator.run() + + const expected = directories.map((directory) => + path.resolve(root, directory), + ) + const snapshot = generator.getWatchDependencies() + expect(snapshot.directories).toEqual(expected) + const files = [...snapshot.files] + if (typeof config.virtualRouteConfig === 'string') { + expect(files).toContain(path.resolve(root, config.virtualRouteConfig)) + } + snapshot.directories[0] = path.resolve(root, 'changed') + snapshot.directories.push(path.resolve(root, 'extra')) + snapshot.files.push(path.resolve(root, 'extra.ts')) + expect(generator.getWatchDependencies().directories).toEqual(expected) + expect(generator.getWatchDependencies().files).toEqual(files) + }, + ) + it.each(folderNames)( 'should create directory for routeTree if it does not exist', async () => { diff --git a/packages/router-generator/tests/virtual-config.test.ts b/packages/router-generator/tests/virtual-config.test.ts new file mode 100644 index 00000000000..9d3fae6bc3e --- /dev/null +++ b/packages/router-generator/tests/virtual-config.test.ts @@ -0,0 +1,300 @@ +import { + mkdir, + mkdtemp, + readFile, + rename, + rm, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { expect, test } from 'vitest' +import { Generator, getConfig } from '../src' + +test.each( + ['relative', 'absolute'].flatMap((configPath) => + [ + { failure: 'syntax', source: 'export default {' }, + { + failure: 'schema', + source: "export default { type: 'root', file: 42 }", + }, + ].map((invalid) => ({ configPath, ...invalid })), + ), +)( + 'recovers from an initial $failure error in a $configPath virtual configuration', + async ({ configPath, source }) => { + const root = await mkdtemp(path.join(tmpdir(), 'router-virtual-recovery-')) + const routes = path.join(root, 'routes') + const configFile = path.join(root, 'virtual-routes.ts') + const generatedRouteTree = path.join(root, 'routeTree.gen.ts') + try { + await mkdir(routes) + await Promise.all([ + writeFile( + path.join(routes, '__root.tsx'), + `import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute({})\n`, + ), + writeFile(configFile, source), + ]) + const generator = new Generator({ + root, + config: getConfig( + { + routesDirectory: routes, + generatedRouteTree, + virtualRouteConfig: + configPath === 'relative' ? 'virtual-routes.ts' : configFile, + tmpDir: path.join(root, 'tmp'), + disableLogging: true, + }, + root, + ), + }) + await expect(generator.run()).rejects.toThrow() + expect(generator.getWatchDependencies().files).toEqual([configFile]) + await writeFile( + configFile, + "export default { type: 'root', file: '__root.tsx' }\n", + ) + await generator.run({ path: configFile, type: 'update' }) + expect(await readFile(generatedRouteTree, 'utf8')).toContain( + "import { Route as rootRouteImport } from './routes/__root'", + ) + expect(generator.getWatchDependencies().files).toEqual([configFile]) + } finally { + await rm(root, { recursive: true, force: true }) + } + }, +) + +test.each([ + { source: 'configuration', depth: 0 }, + { source: 'helper', depth: 1 }, + { source: 'transitive helper', depth: 2 }, +])('reloads an edited $source from its file event', async ({ depth }) => { + const root = await mkdtemp(path.join(tmpdir(), 'router-virtual-config-')) + const routes = path.join(root, 'routes') + const configFile = path.join(root, 'virtual-routes.ts') + const editedFile = depth ? path.join(root, `helper${depth}.ts`) : configFile + const generatedRouteTree = path.join(root, 'routeTree.gen.ts') + const virtualConfig = (name: string) => + `export default ${JSON.stringify({ + type: 'root', + file: '__root.tsx', + children: [{ type: 'route', path: `/${name}`, file: `${name}.tsx` }], + })}\n` + + try { + await mkdir(routes) + await Promise.all([ + writeFile( + path.join(routes, '__root.tsx'), + `import { createRootRoute } from '@tanstack/react-router' +export const Route = createRootRoute({})\n`, + ), + ...['before', 'after', 'replacement'].map((name) => + writeFile( + path.join(routes, `${name}.tsx`), + `import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/${name}')({})\n`, + ), + ), + writeFile(editedFile, virtualConfig('before')), + ...Array.from({ length: depth }, (_, index) => + writeFile( + index ? path.join(root, `helper${index}.ts`) : configFile, + `export { default } from './helper${index + 1}'\n`, + ), + ), + ]) + const generator = new Generator({ + root, + config: getConfig( + { + routesDirectory: routes, + generatedRouteTree, + virtualRouteConfig: 'virtual-routes.ts', + tmpDir: path.join(root, 'tmp'), + disableLogging: true, + }, + root, + ), + }) + + await generator.run() + expect(await readFile(generatedRouteTree, 'utf8')).toContain("'/before'") + const dependencies = [ + configFile, + ...Array.from({ length: depth }, (_, index) => + path.join(root, `helper${index + 1}.ts`), + ), + ] + expect(generator.getWatchDependencies()).toEqual({ + files: expect.arrayContaining(dependencies), + directories: [], + }) + expect(generator.getWatchDependencies().files).toHaveLength( + dependencies.length, + ) + + const isolatedConfig = path.join(root, 'isolated-routes.ts') + await writeFile(isolatedConfig, virtualConfig('replacement')) + const isolated = new Generator({ + root, + config: getConfig( + { + routesDirectory: routes, + generatedRouteTree: path.join(root, 'isolated-routeTree.gen.ts'), + virtualRouteConfig: isolatedConfig, + tmpDir: path.join(root, 'tmp'), + disableLogging: true, + }, + root, + ), + }) + await isolated.run() + expect(isolated.getWatchDependencies().files).toEqual([isolatedConfig]) + expect(generator.getWatchDependencies().files).toEqual( + expect.arrayContaining(dependencies), + ) + expect(generator.getWatchDependencies().files).toHaveLength( + dependencies.length, + ) + + await writeFile(editedFile, virtualConfig('after')) + await generator.run({ path: editedFile, type: 'update' }) + const updated = await readFile(generatedRouteTree, 'utf8') + expect(updated).toContain("'/after'") + expect(updated).not.toContain("'/before'") + + await generator.run({ path: editedFile, type: 'update' }) + expect(await readFile(generatedRouteTree, 'utf8')).toBe(updated) + + await writeFile(`${editedFile}.next`, virtualConfig('replacement')) + await rename(`${editedFile}.next`, editedFile) + await generator.run({ path: editedFile, type: 'update' }) + const replaced = await readFile(generatedRouteTree, 'utf8') + expect(replaced).toContain("'/replacement'") + expect(replaced).not.toContain("'/after'") + expect(replaced).not.toContain("'/before'") + + await writeFile(configFile, virtualConfig('before')) + await generator.run({ path: configFile, type: 'update' }) + expect(await readFile(generatedRouteTree, 'utf8')).toContain("'/before'") + expect(generator.getWatchDependencies().files).toEqual([configFile]) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('watches imports awaited by a nested __virtual factory', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'router-virtual-factory-')) + const routes = path.join(root, 'routes') + const nested = path.join(routes, 'nested') + const configFile = path.join(nested, '__virtual.ts') + const helperFile = path.join(root, 'helper.ts') + const leafFile = path.join(root, 'leaf.ts') + const generatedRouteTree = path.join(root, 'routeTree.gen.ts') + const subtree = (name: string) => + `export default [{ type: 'route', path: '/${name}', file: '${name}.tsx' }]\n` + try { + await mkdir(nested, { recursive: true }) + await Promise.all([ + writeFile( + path.join(routes, '__root.tsx'), + `import { createRootRoute } from '@tanstack/react-router'; export const Route = createRootRoute({})\n`, + ), + ...['before', 'after'].map((name) => + writeFile( + path.join(nested, `${name}.tsx`), + `import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/nested/${name}')({})\n`, + ), + ), + writeFile( + configFile, + `export default async () => (await import('../../helper')).default\n`, + ), + writeFile(helperFile, `export { default } from './leaf'\n`), + writeFile(leafFile, subtree('before')), + ]) + const generator = new Generator({ + root, + config: getConfig( + { + routesDirectory: routes, + generatedRouteTree, + tmpDir: path.join(root, 'tmp'), + disableLogging: true, + }, + root, + ), + }) + await generator.run() + expect(await readFile(generatedRouteTree, 'utf8')).toContain( + "'/nested/before'", + ) + expect(generator.getWatchDependencies()).toEqual({ + files: expect.arrayContaining([configFile, helperFile, leafFile]), + directories: [routes], + }) + expect(generator.getWatchDependencies().files).toHaveLength(3) + await writeFile(leafFile, subtree('after')) + await generator.run({ path: leafFile, type: 'update' }) + const updated = await readFile(generatedRouteTree, 'utf8') + expect(updated).toContain("'/nested/after'") + expect(updated).not.toContain("'/nested/before'") + + await writeFile(configFile, subtree('before')) + await generator.run({ path: configFile, type: 'update' }) + expect(await readFile(generatedRouteTree, 'utf8')).toContain( + "'/nested/before'", + ) + expect(generator.getWatchDependencies().files).toEqual([configFile]) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('evaluates a cyclic virtual configuration once per generation', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'router-virtual-cycle-')) + const routes = path.join(root, 'routes') + const configFile = path.join(root, 'virtual-routes.ts') + const evaluationLog = path.join(root, 'evaluations.log') + try { + await mkdir(routes) + await writeFile( + path.join(routes, '__root.tsx'), + `import { createRootRoute } from '@tanstack/react-router'; export const Route = createRootRoute({})\n`, + ) + await writeFile( + configFile, + `import { appendFileSync } from 'node:fs' +import './virtual-routes' +appendFileSync(${JSON.stringify(evaluationLog)}, 'run\\n') +export default { type: 'root', file: '__root.tsx', children: [] } +`, + ) + const generator = new Generator({ + root, + config: getConfig( + { + routesDirectory: routes, + virtualRouteConfig: configFile, + generatedRouteTree: path.join(root, 'routeTree.gen.ts'), + tmpDir: path.join(root, 'tmp'), + disableLogging: true, + }, + root, + ), + }) + await generator.run() + expect(await readFile(evaluationLog, 'utf8')).toBe('run\n') + await generator.run({ path: configFile, type: 'update' }) + expect(await readFile(evaluationLog, 'utf8')).toBe('run\nrun\n') + expect(generator.getWatchDependencies().files).toEqual([configFile]) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/router-plugin/package.json b/packages/router-plugin/package.json index b4bc9a2a9ea..ecd1a4899e7 100644 --- a/packages/router-plugin/package.json +++ b/packages/router-plugin/package.json @@ -103,6 +103,16 @@ "default": "./dist/cjs/esbuild.cjs" } }, + "./bun": { + "import": { + "types": "./dist/esm/bun.d.ts", + "default": "./dist/esm/bun.js" + }, + "require": { + "types": "./dist/cjs/bun.d.cts", + "default": "./dist/cjs/bun.cjs" + } + }, "./package.json": "./package.json" }, "sideEffects": false, @@ -132,6 +142,7 @@ "@types/babel__core": "^7.20.5", "@types/babel__template": "^7.4.4", "@types/node": ">=20", + "bun-types-no-globals": "^1.4.3", "@tanstack/vite-config": "catalog:", "vitest": "catalog:", "jsdom": "catalog:", @@ -145,6 +156,15 @@ "rimraf": "catalog:", "webpack": "^5.104.1" }, + "nx": { + "targets": { + "test:unit": { + "dependsOn": [ + "build" + ] + } + } + }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "workspace:^", diff --git a/packages/router-plugin/src/bun.ts b/packages/router-plugin/src/bun.ts new file mode 100644 index 00000000000..0501770c301 --- /dev/null +++ b/packages/router-plugin/src/bun.ts @@ -0,0 +1,20 @@ +import { createBunPlugin } from 'unplugin' +import { configSchema } from './core/config' +import { unpluginRouterComposedFactory } from './core/router-composed-plugin' +import type { BunPlugin } from 'unplugin' +import type { Config } from './core/config' + +type RouterPluginOptions = Partial Config)> | undefined + +const TanStackRouterBun = (options?: RouterPluginOptions): BunPlugin => ({ + name: 'tanstack-router', + setup(build) { + // A plugin can be reused by overlapping builds; compiler state is build-local. + return createBunPlugin(unpluginRouterComposedFactory)(options).setup(build) + }, +}) +const tanstackRouter = TanStackRouterBun + +export default TanStackRouterBun +export { configSchema, TanStackRouterBun, tanstackRouter } +export type { CodeSplittingOptions, Config } from './core/config' diff --git a/packages/router-plugin/src/core/code-splitter/plugins/framework-plugins.ts b/packages/router-plugin/src/core/code-splitter/plugins/framework-plugins.ts index a0351ad2103..8c06ef9f211 100644 --- a/packages/router-plugin/src/core/code-splitter/plugins/framework-plugins.ts +++ b/packages/router-plugin/src/core/code-splitter/plugins/framework-plugins.ts @@ -3,16 +3,18 @@ import { createReactRefreshRouteComponentsPlugin } from './react-refresh-route-c import { createReactStableHmrSplitRouteComponentsPlugin } from './react-stable-hmr-split-route-components' import type { CodeSplitCompilerPlugin } from '../plugins' import type { Config, HmrStyle } from '../../config' +import type { UnpluginContextMeta } from 'unplugin' export function getFrameworkHmrCompilerPlugins(opts: { targetFramework: Config['target'] hmrStyle?: HmrStyle + framework?: UnpluginContextMeta['framework'] }): Array | undefined { switch (opts.targetFramework) { case 'react': { const hmrStyle = opts.hmrStyle ?? 'vite' return [ - ...(hmrStyle === 'vite' + ...(hmrStyle === 'vite' && opts.framework !== 'bun' ? [createReactRefreshIgnoredRouteExportsPlugin()] : []), createReactRefreshRouteComponentsPlugin(), diff --git a/packages/router-plugin/src/core/code-splitter/plugins/react-stable-hmr-split-route-components.ts b/packages/router-plugin/src/core/code-splitter/plugins/react-stable-hmr-split-route-components.ts index 99448f452ad..2bb691294cf 100644 --- a/packages/router-plugin/src/core/code-splitter/plugins/react-stable-hmr-split-route-components.ts +++ b/packages/router-plugin/src/core/code-splitter/plugins/react-stable-hmr-split-route-components.ts @@ -15,8 +15,7 @@ function createHotDataKey(exportName: string) { const buildStableSplitComponentStatements = template.statements( ` const %%stableComponentIdent%% = (() => { - const hot = %%hotExpression%% - const hotData = hot ? (hot.data ??= {}) : undefined + const hotData = %%hotExpression%% ? ((%%hotExpression%%).data ??= {}) : undefined return hotData?.[%%hotDataKey%%] ?? %%lazyRouteComponentIdent%%(%%localImporterIdent%%, %%exporterIdent%%) })() if (%%hotExpression%%) { diff --git a/packages/router-plugin/src/core/hmr/vite-adapter.ts b/packages/router-plugin/src/core/hmr/vite-adapter.ts index ba8d42a80ad..8419b9e01f3 100644 --- a/packages/router-plugin/src/core/hmr/vite-adapter.ts +++ b/packages/router-plugin/src/core/hmr/vite-adapter.ts @@ -26,8 +26,7 @@ export function createViteHmrStatement( template.statement( ` if (import.meta.hot) { - const hot = import.meta.hot - const hotData = hot.data ??= {} + const hotData = import.meta.hot.data ??= {} const handleRouteUpdate = ${handleRouteUpdateCode} const initialRouteId = ${routeIdFallback} ?? hotData['tsr-route-id'] if (initialRouteId) { @@ -41,7 +40,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? ${routeIdFallback} if (routeId) { diff --git a/packages/router-plugin/src/core/router-code-splitter-plugin.ts b/packages/router-plugin/src/core/router-code-splitter-plugin.ts index 596bd6e0120..8be2f093e5b 100644 --- a/packages/router-plugin/src/core/router-code-splitter-plugin.ts +++ b/packages/router-plugin/src/core/router-code-splitter-plugin.ts @@ -3,6 +3,8 @@ * https://github.com/TanStack/router/pull/3355 */ +import { readFile } from 'node:fs/promises' +import { dirname, extname, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { decodeIdentifier, logDiff } from '@tanstack/router-utils' import { getConfig, splitGroupingsSchema } from './config' @@ -17,6 +19,7 @@ import { getFrameworkHmrCompilerPlugins } from './code-splitter/plugins/framewor import { defaultCodeSplitGroupings, splitRouteIdentNodes, + tsrShared, tsrSplit, } from './constants' import { debug, normalizePath, routeFactoryCallCodeFilter } from './utils' @@ -27,13 +30,69 @@ import type { CodeSplitCompilerPlugin } from './code-splitter/plugins' import type { Config, HmrStyle } from './config' import type { RouterPluginContext } from './router-plugin-context' import type { + UnpluginContextMeta, UnpluginFactory, + UnpluginOptions, TransformResult as UnpluginTransformResult, } from 'unplugin' const CODE_SPLITTER_PLUGIN_NAME = 'tanstack-router:code-splitter:compile-reference-file' +// Query identities are separate Bun modules, backed by the original route source. +function bunRouteModule( + parameter: typeof tsrSplit | typeof tsrShared, + transform: (code: string, id: string) => UnpluginTransformResult, +): NonNullable { + const namespace = `tanstack-router-${parameter}` + return { + setup(build) { + build.onResolve({ filter: new RegExp(`[?&]${parameter}=`) }, (args) => { + const [filename, query] = args.path.split('?') + return { + path: `${normalizePath(resolve(args.resolveDir, filename!))}?${query}`, + namespace, + } + }) + build.onLoad({ filter: /.*/, namespace }, async (args) => { + const [filename] = args.path.split('?') + const code = await readFile(filename!, 'utf8') + const result = transform(code, args.path) + const extension = extname(filename!) + let contents = + typeof result === 'string' ? result : (result?.code ?? code) + if ( + build.config.sourcemap && + build.config.sourcemap !== 'none' && + result && + typeof result !== 'string' && + result.map + ) { + const map = + typeof result.map === 'string' ? JSON.parse(result.map) : result.map + // The query selects a module; mappings still refer to the physical file. + const sourceMap = { ...map, sources: [pathToFileURL(filename!).href] } + contents += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from(JSON.stringify(sourceMap)).toString('base64')}` + } + return { + contents, + loader: + build.config.loader?.[extension] ?? + (extension === '.tsx' + ? 'tsx' + : extension === '.jsx' + ? 'jsx' + : /\.[cm]?ts$/.test(filename!) + ? 'ts' + : 'js'), + resolveDir: dirname(filename!), + watchFiles: [filename!], + } + }) + }, + } +} + type TransformationPluginInfo = { pluginNames: Array pkg: string @@ -80,6 +139,7 @@ const TRANSFORMATION_PLUGINS_BY_FRAMEWORK: Record< export function createRouterCodeSplitterPlugin( options: Partial Config)> | undefined = {}, routerPluginContext: RouterPluginContext, + framework?: UnpluginContextMeta['framework'], ): ReturnType Config)> | undefined>> { let ROOT: string = process.cwd() let userConfig: Config @@ -104,6 +164,7 @@ export function createRouterCodeSplitterPlugin( ? (getFrameworkHmrCompilerPlugins({ targetFramework: userConfig.target, hmrStyle, + framework, }) ?? []) : []), ...(userConfig.codeSplittingOptions?.compilerPlugins ?? []), @@ -252,11 +313,52 @@ export function createRouterCodeSplitterPlugin( return result } + const handleCompilingSharedFile = ( + code: string, + id: string, + ): UnpluginTransformResult => { + const url = pathToFileURL(id) + url.searchParams.delete('v') + const normalizedId = normalizePath(fileURLToPath(url)) + const [baseId] = normalizedId.split('?') + + if (!baseId) { + return null + } + const sharedBindings = sharedBindingsMap.get(baseId) + if (!sharedBindings || sharedBindings.size === 0) { + return null + } + if (debug) { + console.info('Compiling Shared Module: ', id) + } + const result = compileCodeSplitSharedRoute({ + code, + sharedBindings, + filename: normalizedId, + }) + if (debug) { + logDiff(code, result.code) + console.log('Output:\n', result.code + '\n\n') + } + return result + } + return [ { name: 'tanstack-router:code-splitter:compile-reference-file', enforce: 'pre', + bun: { + setup(build) { + isProduction = !( + 'reactFastRefresh' in build.config && build.config.reactFastRefresh + ) + initUserConfig() + sharedBindingsMap.clear() + }, + }, + transform: { filter: { id: { @@ -345,6 +447,8 @@ export function createRouterCodeSplitterPlugin( name: 'tanstack-router:code-splitter:compile-virtual-file', enforce: 'pre', + bun: bunRouteModule(tsrSplit, handleCompilingVirtualFile), + transform: { filter: { id: /tsr-split/, @@ -370,36 +474,13 @@ export function createRouterCodeSplitterPlugin( name: 'tanstack-router:code-splitter:compile-shared-file', enforce: 'pre', + bun: bunRouteModule(tsrShared, handleCompilingSharedFile), + transform: { filter: { id: /tsr-shared/, }, - handler(code, id) { - const url = pathToFileURL(id) - url.searchParams.delete('v') - const normalizedId = normalizePath(fileURLToPath(url)) - const [baseId] = normalizedId.split('?') - - if (!baseId) return null - - const sharedBindings = sharedBindingsMap.get(baseId) - if (!sharedBindings || sharedBindings.size === 0) return null - - if (debug) console.info('Compiling Shared Module: ', id) - - const result = compileCodeSplitSharedRoute({ - code, - sharedBindings, - filename: normalizedId, - }) - - if (debug) { - logDiff(code, result.code) - console.log('Output:\n', result.code + '\n\n') - } - - return result - }, + handler: handleCompilingSharedFile, }, vite: { @@ -416,6 +497,10 @@ export function createRouterCodeSplitterPlugin( export const unpluginRouterCodeSplitterFactory: UnpluginFactory< Partial Config)> | undefined -> = (options = {}) => { - return createRouterCodeSplitterPlugin(options, createRouterPluginContext()) +> = (options = {}, meta) => { + return createRouterCodeSplitterPlugin( + options, + createRouterPluginContext(), + meta.framework, + ) } diff --git a/packages/router-plugin/src/core/router-composed-plugin.ts b/packages/router-plugin/src/core/router-composed-plugin.ts index c778e4e8d47..60fc7631801 100644 --- a/packages/router-plugin/src/core/router-composed-plugin.ts +++ b/packages/router-plugin/src/core/router-composed-plugin.ts @@ -4,6 +4,7 @@ import { createRouterCodeSplitterPlugin } from './router-code-splitter-plugin' import { createRouterHmrPlugin } from './router-hmr-plugin' import { createRouterPluginContext } from './router-plugin-context' import type { Config } from './config' +import type { PluginBuilder as BunPluginBuilder } from 'bun' import type { RspackCompiler, UnpluginFactory, @@ -30,7 +31,7 @@ function applyRspackInlineCssDefaultDefinePlugin(compiler: RspackCompiler) { export const unpluginRouterComposedFactory: UnpluginFactory< Partial Config)> | undefined -> = (options = {}, _meta) => { +> = (options = {}, meta) => { const ROOT: string = process.cwd() const userConfig = getConfig( (typeof options === 'function' ? options() : options) as Partial, @@ -46,15 +47,27 @@ export const unpluginRouterComposedFactory: UnpluginFactory< } const routerGenerator = getPlugin( - createRouterGeneratorPlugin(options, routerPluginContext), + createRouterGeneratorPlugin(options, routerPluginContext, meta.framework), ) const routerCodeSplitter = getPlugin( - createRouterCodeSplitterPlugin(options, routerPluginContext), + createRouterCodeSplitterPlugin( + options, + routerPluginContext, + meta.framework, + ), ) const result = [ { name: 'tanstack:router-inline-css-defaults', + bun: { + setup(build: BunPluginBuilder) { + build.config.define = { + ...INLINE_CSS_DEFAULT_DEFINES, + ...build.config.define, + } + }, + }, vite: { config() { return { @@ -87,9 +100,12 @@ export const unpluginRouterComposedFactory: UnpluginFactory< const isProduction = process.env.NODE_ENV === 'production' - if (!isProduction && !userConfig.autoCodeSplitting) { + if ( + (meta.framework === 'bun' || !isProduction) && + !userConfig.autoCodeSplitting + ) { const routerHmr = getPlugin( - createRouterHmrPlugin(options, routerPluginContext), + createRouterHmrPlugin(options, routerPluginContext, meta.framework), ) result.push(...routerHmr) } diff --git a/packages/router-plugin/src/core/router-generator-plugin.ts b/packages/router-plugin/src/core/router-generator-plugin.ts index 50dde9f55cc..baab102ecbe 100644 --- a/packages/router-plugin/src/core/router-generator-plugin.ts +++ b/packages/router-plugin/src/core/router-generator-plugin.ts @@ -1,11 +1,22 @@ -import { isAbsolute, join, normalize } from 'node:path' +import { realpathSync } from 'node:fs' +import { readFile, readdir, realpath } from 'node:fs/promises' +import { + basename, + dirname, + extname, + isAbsolute, + join, + normalize, + resolve, +} from 'node:path' import { Generator, resolveConfigPath } from '@tanstack/router-generator' import { getConfig } from './config' import { createRouterPluginContext } from './router-plugin-context' +import { normalizePath } from './utils' import type { GeneratorEvent } from '@tanstack/router-generator' import type { FSWatcher } from 'chokidar' -import type { UnpluginFactory } from 'unplugin' +import type { UnpluginContextMeta, UnpluginFactory } from 'unplugin' import type { Config } from './config' import type { RouterPluginContext } from './router-plugin-context' @@ -14,6 +25,7 @@ const PLUGIN_NAME = 'unplugin:router-generator' export function createRouterGeneratorPlugin( options: Partial Config)> | undefined = {}, routerPluginContext: RouterPluginContext, + framework?: UnpluginContextMeta['framework'], ): ReturnType Config)> | undefined>> { let ROOT: string = process.cwd() let userConfig: Config @@ -40,6 +52,24 @@ export function createRouterGeneratorPlugin( }) } + const runGenerator = async (event?: GeneratorEvent) => { + await generator.run(event) + const routesByFile = generator.getRoutesByFileMap() + // Bun resolves symlinks before invoking its module hooks. + routerPluginContext.routesByFile = + framework === 'bun' + ? new Map( + await Promise.all( + Array.from( + routesByFile, + async ([file, route]) => + [normalizePath(await realpath(file)), route] as const, + ), + ), + ) + : routesByFile + } + const generate = async (opts?: { file: string event: 'create' | 'update' | 'delete' @@ -55,8 +85,7 @@ export function createRouterGeneratorPlugin( } try { - await generator.run(generatorEvent) - routerPluginContext.routesByFile = generator.getRoutesByFileMap() + await runGenerator(generatorEvent) } catch (e) { console.error(e) } @@ -71,6 +100,68 @@ export function createRouterGeneratorPlugin( event, }) }, + bun: { + setup(build) { + initConfigAndGenerator() + // Reject generation errors instead of bundling a stale route tree. + build.onStart(() => runGenerator()) + if ( + 'reactFastRefresh' in build.config && + build.config.reactFastRefresh + ) { + const configuredRouteTree = resolve( + ROOT, + userConfig.generatedRouteTree, + ) + const generatedRouteTree = join( + realpathSync.native(dirname(configuredRouteTree)), + basename(configuredRouteTree), + ) + const extension = extname(generatedRouteTree) + build.onLoad( + { + filter: new RegExp( + `^${generatedRouteTree.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, + ), + }, + async () => { + const dependencies = generator.getWatchDependencies() + // Bun watches immediate directory entries. Include empty directories + // so a route added there also triggers generation before bundling. + const watchDirs = await Promise.all( + dependencies.directories.map(async (directory) => { + const entries = await readdir(directory, { + recursive: true, + withFileTypes: true, + }) + return [ + directory, + ...entries + .filter((entry) => entry.isDirectory()) + .map((entry) => join(entry.parentPath, entry.name)), + ] + }), + ) + return { + contents: await readFile(generatedRouteTree, 'utf8'), + loader: + build.config.loader?.[extension] ?? + (extension === '.tsx' + ? 'tsx' + : extension === '.jsx' + ? 'jsx' + : /\.[cm]?ts$/.test(extension) + ? 'ts' + : 'js'), + resolveDir: dirname(generatedRouteTree), + watchDirs: watchDirs.flat(), + watchFiles: dependencies.files, + } + }, + ) + } + }, + }, vite: { async configResolved(config) { initConfigAndGenerator({ root: config.root }) @@ -147,6 +238,10 @@ export function createRouterGeneratorPlugin( export const unpluginRouterGeneratorFactory: UnpluginFactory< Partial Config)> | undefined -> = (options = {}) => { - return createRouterGeneratorPlugin(options, createRouterPluginContext()) +> = (options = {}, meta) => { + return createRouterGeneratorPlugin( + options, + createRouterPluginContext(), + meta.framework, + ) } diff --git a/packages/router-plugin/src/core/router-hmr-plugin.ts b/packages/router-plugin/src/core/router-hmr-plugin.ts index e1dc01a43b1..24b98c80cc0 100644 --- a/packages/router-plugin/src/core/router-hmr-plugin.ts +++ b/packages/router-plugin/src/core/router-hmr-plugin.ts @@ -5,7 +5,11 @@ import { createRouteHmrStatement } from './hmr' import { debug, normalizePath, routeFactoryCallCodeFilter } from './utils' import { getConfig } from './config' import { createRouterPluginContext } from './router-plugin-context' -import type { UnpluginFactory } from 'unplugin' +import type { + UnpluginContextMeta, + UnpluginFactory, + UnpluginOptions, +} from 'unplugin' import type { Config } from './config' import type { RouterPluginContext } from './router-plugin-context' @@ -18,6 +22,7 @@ import type { RouterPluginContext } from './router-plugin-context' export function createRouterHmrPlugin( options: Partial Config)> | undefined = {}, routerPluginContext: RouterPluginContext, + framework?: UnpluginContextMeta['framework'], ): ReturnType | undefined>> { let ROOT: string = process.cwd() @@ -27,9 +32,18 @@ export function createRouterHmrPlugin( let userConfig = resolveUserConfig() - return { + const plugin: UnpluginOptions = { name: 'tanstack-router:hmr', enforce: 'pre', + bun: { + setup(build) { + if ( + !('reactFastRefresh' in build.config && build.config.reactFastRefresh) + ) { + plugin.transform = undefined + } + }, + }, transform: { filter: { // this is necessary for webpack / rspack to avoid matching .html files @@ -53,6 +67,7 @@ export function createRouterHmrPlugin( const compilerPlugins = getFrameworkHmrCompilerPlugins({ targetFramework: 'react', hmrStyle, + framework, }) const compiled = compileCodeSplitReferenceRoute({ code, @@ -109,10 +124,15 @@ export function createRouterHmrPlugin( }, }, } + return plugin } export const unpluginRouterHmrFactory: UnpluginFactory< Partial | undefined -> = (options = {}) => { - return createRouterHmrPlugin(options, createRouterPluginContext()) +> = (options = {}, meta) => { + return createRouterHmrPlugin( + options, + createRouterPluginContext(), + meta.framework, + ) } diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx index c771483895a..3f529238f11 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx @@ -5,8 +5,7 @@ import * as React from 'react' import { createFileRoute } from '@tanstack/react-router' import { fetchPosts } from '../posts' const TSRSplitComponent = (() => { - const hot = import.meta.hot - const hotData = hot ? (hot.data ??= {}) : undefined + const hotData = import.meta.hot ? (import.meta.hot.data ??= {}) : undefined return ( hotData?.['tsr-split-component:component'] ?? lazyRouteComponent($$splitComponentImporter, 'component') @@ -39,8 +38,7 @@ export function TSRFastRefreshAnchor() { return null } if (import.meta.hot) { - const hot = import.meta.hot - const hotData = (hot.data ??= {}) + const hotData = (import.meta.hot.data ??= {}) const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { const router = window.__TSR_ROUTER__ const oldRoute = router.routesById[routeId] @@ -101,7 +99,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx index eb655d02877..2dd93d236df 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@webpack-hot.tsx @@ -5,8 +5,9 @@ import * as React from 'react' import { createFileRoute } from '@tanstack/react-router' import { fetchPosts } from '../posts' const TSRSplitComponent = (() => { - const hot = import.meta.webpackHot - const hotData = hot ? (hot.data ??= {}) : undefined + const hotData = import.meta.webpackHot + ? (import.meta.webpackHot.data ??= {}) + : undefined return ( hotData?.['tsr-split-component:component'] ?? lazyRouteComponent($$splitComponentImporter, 'component') diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx index bf94d5d3fd2..cd8539367c4 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createFileRoute-lowercase-components@true.tsx @@ -5,8 +5,7 @@ const $$splitErrorComponentImporter = () => import { lazyRouteComponent } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router' const TSRSplitErrorComponent = (() => { - const hot = import.meta.hot - const hotData = hot ? (hot.data ??= {}) : undefined + const hotData = import.meta.hot ? (import.meta.hot.data ??= {}) : undefined return ( hotData?.['tsr-split-component:errorComponent'] ?? lazyRouteComponent($$splitErrorComponentImporter, 'errorComponent') @@ -17,8 +16,7 @@ if (import.meta.hot) { TSRSplitErrorComponent } const TSRSplitComponent = (() => { - const hot = import.meta.hot - const hotData = hot ? (hot.data ??= {}) : undefined + const hotData = import.meta.hot ? (import.meta.hot.data ??= {}) : undefined return ( hotData?.['tsr-split-component:component'] ?? lazyRouteComponent($$splitComponentImporter, 'component') @@ -58,8 +56,7 @@ export function TSRFastRefreshAnchor() { return null } if (import.meta.hot) { - const hot = import.meta.hot - const hotData = (hot.data ??= {}) + const hotData = (import.meta.hot.data ??= {}) const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { const router = window.__TSR_ROUTER__ const oldRoute = router.routesById[routeId] @@ -120,7 +117,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx index f12187ccd1a..0f165963312 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-inline-component@true.tsx @@ -24,8 +24,7 @@ export function TSRFastRefreshAnchor() { return null } if (import.meta.hot) { - const hot = import.meta.hot - const hotData = (hot.data ??= {}) + const hotData = (import.meta.hot.data ??= {}) const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { const router = window.__TSR_ROUTER__ const oldRoute = router.routesById[routeId] @@ -86,7 +85,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx index 9f8df0d43f5..5a2cbcdc051 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute-lowercase-components@true.tsx @@ -40,8 +40,7 @@ export function TSRFastRefreshAnchor() { return null } if (import.meta.hot) { - const hot = import.meta.hot - const hotData = (hot.data ??= {}) + const hotData = (import.meta.hot.data ??= {}) const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { const router = window.__TSR_ROUTER__ const oldRoute = router.routesById[routeId] @@ -102,7 +101,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx index 859380ef535..e91bcc84f3e 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRoute@true.tsx @@ -23,8 +23,7 @@ export function TSRFastRefreshAnchor() { return null } if (import.meta.hot) { - const hot = import.meta.hot - const hotData = (hot.data ??= {}) + const hotData = (import.meta.hot.data ??= {}) const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { const router = window.__TSR_ROUTER__ const oldRoute = router.routesById[routeId] @@ -85,7 +84,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx index 9d942f65a2b..358972aaa0a 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/createRootRouteWithContext-type-args@true.tsx @@ -31,8 +31,7 @@ export function TSRFastRefreshAnchor() { return null } if (import.meta.hot) { - const hot = import.meta.hot - const hotData = (hot.data ??= {}) + const hotData = (import.meta.hot.data ??= {}) const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { const router = window.__TSR_ROUTER__ const oldRoute = router.routesById[routeId] @@ -93,7 +92,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx index 555c8fdd6cd..4ec3589a754 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/explicit-undefined-component@true.tsx @@ -22,8 +22,7 @@ export function TSRFastRefreshAnchor() { return null } if (import.meta.hot) { - const hot = import.meta.hot - const hotData = (hot.data ??= {}) + const hotData = (import.meta.hot.data ??= {}) const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { const router = window.__TSR_ROUTER__ const oldRoute = router.routesById[routeId] @@ -84,7 +83,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx index 9e27af7d699..a98bdb7498d 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx @@ -5,8 +5,7 @@ import * as React from 'react' import { createFileRoute } from '@tanstack/react-router' import { fetchPosts } from '../posts' const TSRSplitComponent = (() => { - const hot = import.meta.hot - const hotData = hot ? (hot.data ??= {}) : undefined + const hotData = import.meta.hot ? (import.meta.hot.data ??= {}) : undefined return ( hotData?.['tsr-split-component:component'] ?? lazyRouteComponent($$splitComponentImporter, 'component') @@ -39,8 +38,7 @@ export function TSRFastRefreshAnchor() { return null } if (import.meta.hot) { - const hot = import.meta.hot - const hotData = (hot.data ??= {}) + const hotData = (import.meta.hot.data ??= {}) const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { const router = window.__TSR_ROUTER__ const oldRoute = router.routesById[routeId] @@ -101,7 +99,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx index 1a134b88da2..609cfe7987e 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/multi-component@true.tsx @@ -7,8 +7,7 @@ import * as React from 'react' import { createFileRoute } from '@tanstack/react-router' import { fetchPosts } from '../posts' const TSRSplitComponent = (() => { - const hot = import.meta.hot - const hotData = hot ? (hot.data ??= {}) : undefined + const hotData = import.meta.hot ? (import.meta.hot.data ??= {}) : undefined return ( hotData?.['tsr-split-component:component'] ?? lazyRouteComponent($$splitComponentImporter, 'component') @@ -19,8 +18,7 @@ if (import.meta.hot) { TSRSplitComponent } const TSRSplitErrorComponent = (() => { - const hot = import.meta.hot - const hotData = hot ? (hot.data ??= {}) : undefined + const hotData = import.meta.hot ? (import.meta.hot.data ??= {}) : undefined return ( hotData?.['tsr-split-component:errorComponent'] ?? lazyRouteComponent($$splitErrorComponentImporter, 'errorComponent') @@ -54,8 +52,7 @@ export function TSRFastRefreshAnchor() { return null } if (import.meta.hot) { - const hot = import.meta.hot - const hotData = (hot.data ??= {}) + const hotData = (import.meta.hot.data ??= {}) const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { const router = window.__TSR_ROUTER__ const oldRoute = router.routesById[routeId] @@ -116,7 +113,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx index b3bc82d69ed..a2a7e0bf3f6 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/string-literal-keys@true.tsx @@ -7,8 +7,7 @@ import * as React from 'react' import { createFileRoute } from '@tanstack/react-router' import { fetchPosts } from '../posts' const TSRSplitComponent = (() => { - const hot = import.meta.hot - const hotData = hot ? (hot.data ??= {}) : undefined + const hotData = import.meta.hot ? (import.meta.hot.data ??= {}) : undefined return ( hotData?.['tsr-split-component:component'] ?? lazyRouteComponent($$splitComponentImporter, 'component') @@ -19,8 +18,7 @@ if (import.meta.hot) { TSRSplitComponent } const TSRSplitErrorComponent = (() => { - const hot = import.meta.hot - const hotData = hot ? (hot.data ??= {}) : undefined + const hotData = import.meta.hot ? (import.meta.hot.data ??= {}) : undefined return ( hotData?.['tsr-split-component:errorComponent'] ?? lazyRouteComponent($$splitErrorComponentImporter, 'errorComponent') @@ -54,8 +52,7 @@ export function TSRFastRefreshAnchor() { return null } if (import.meta.hot) { - const hot = import.meta.hot - const hotData = (hot.data ??= {}) + const hotData = (import.meta.hot.data ??= {}) const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { const router = window.__TSR_ROUTER__ const oldRoute = router.routesById[routeId] @@ -116,7 +113,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx index 05fa745c4b5..bc531f9000e 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx @@ -8,8 +8,7 @@ export const Route = createFileRoute('/posts')({ component: lazyRouteComponent($$splitComponentImporter, 'component'), }) if (import.meta.hot) { - const hot = import.meta.hot - const hotData = (hot.data ??= {}) + const hotData = (import.meta.hot.data ??= {}) const handleRouteUpdate = function handleRouteUpdate(routeId, newRoute) { const router = window.__TSR_ROUTER__ const oldRoute = router.routesById[routeId] @@ -64,7 +63,7 @@ if (import.meta.hot) { handleRouteUpdate(initialRouteId, Route) hotData['tsr-route-update-handled'] = Route } - hot.accept((newModule) => { + import.meta.hot.accept((newModule) => { if (Route && newModule && newModule.Route) { const routeId = hotData['tsr-route-id'] ?? Route.id if (routeId) { diff --git a/packages/router-plugin/tests/bun.test-d.ts b/packages/router-plugin/tests/bun.test-d.ts new file mode 100644 index 00000000000..9d3ff3214d2 --- /dev/null +++ b/packages/router-plugin/tests/bun.test-d.ts @@ -0,0 +1,17 @@ +import { expectTypeOf, test } from 'vitest' +import TanStackRouterBun, { tanstackRouter } from '@tanstack/router-plugin/bun' +import type { BuildConfig, BunPlugin } from 'bun' +import type { Config } from '@tanstack/router-plugin/bun' + +test('accepts the published plugin in a native Bun build', () => { + expectTypeOf>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + typeof tanstackRouter + >() + expectTypeOf>>().toMatchTypeOf< + BuildConfig['plugins'] + >() + expectTypeOf>().toMatchTypeOf< + Parameters[0] + >() +}) diff --git a/packages/router-plugin/tsconfig.json b/packages/router-plugin/tsconfig.json index 0e74c8c099c..82c36ce2f47 100644 --- a/packages/router-plugin/tsconfig.json +++ b/packages/router-plugin/tsconfig.json @@ -4,6 +4,6 @@ "exclude": ["tests/**/test-files/**", "tests/**/snapshots/**"], "compilerOptions": { "jsx": "react-jsx", - "types": ["node"] + "types": ["node", "bun-types-no-globals"] } } diff --git a/packages/router-plugin/vite.config.ts b/packages/router-plugin/vite.config.ts index e80f073d218..007d1bd28d3 100644 --- a/packages/router-plugin/vite.config.ts +++ b/packages/router-plugin/vite.config.ts @@ -22,6 +22,7 @@ export default mergeConfig( './src/rspack.ts', './src/webpack.ts', './src/esbuild.ts', + './src/bun.ts', ], srcDir: './src', }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a476288b663..6e92dbf7d52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1102,6 +1102,31 @@ importers: specifier: ^8.3.4 version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) + e2e/react-router/bun-basic-file-based: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/router-plugin': + specifier: workspace:* + version: link:../../../packages/router-plugin + '@tanstack/virtual-file-routes': + specifier: workspace:* + version: link:../../../packages/virtual-file-routes + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + e2e/react-router/escaped-special-strings: dependencies: '@tanstack/react-router': @@ -14749,7 +14774,7 @@ importers: version: 5.0.0 unplugin: specifier: ^3.3.0 - version: 3.3.0(@rspack/core@2.2.7(@swc/helpers@0.5.23))(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.1)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(webpack@5.110.3) + version: 3.3.0(@rspack/core@2.2.7(@swc/helpers@0.5.23))(bun-types-no-globals@1.4.3)(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.1)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(webpack@5.110.3) vite: specifier: ^8.3.4 version: 8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0) @@ -14784,6 +14809,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.9) + bun-types-no-globals: + specifier: ^1.4.3 + version: 1.4.3 jsdom: specifier: 'catalog:' version: 25.0.1(supports-color@10.2.2) @@ -23375,6 +23403,9 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bun-types-no-globals@1.4.3: + resolution: {integrity: sha512-tQEllXIKGEN+0spgZesvqh2spJMWdeovxO9TbgcCve0CLaug+CoamPGMGGp5bD1vvlpnZI4tny8pi3/ytn1/ZA==} + bun-types@1.2.22: resolution: {integrity: sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA==} peerDependencies: @@ -37996,6 +38027,8 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bun-types-no-globals@1.4.3: {} + bun-types@1.2.22(@types/react@19.2.9): dependencies: '@types/node': 25.0.9 @@ -44003,13 +44036,14 @@ snapshots: picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 - unplugin@3.3.0(@rspack/core@2.2.7(@swc/helpers@0.5.23))(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.1)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(webpack@5.110.3): + unplugin@3.3.0(@rspack/core@2.2.7(@swc/helpers@0.5.23))(bun-types-no-globals@1.4.3)(esbuild@0.28.2)(rolldown@1.2.13)(rollup@4.63.1)(vite@8.3.4(@types/node@25.0.9)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.105.1)(sass@1.97.2)(terser@5.51.2)(tsx@4.20.3)(yaml@2.9.0))(webpack@5.110.3): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.7 webpack-virtual-modules: 0.6.2 optionalDependencies: '@rspack/core': 2.2.7(@swc/helpers@0.5.23) + bun-types-no-globals: 1.4.3 esbuild: 0.28.2 rolldown: 1.2.13 rollup: 4.63.1