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}
+
+
+
+
+}
+`
+}
+
+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