diff --git a/.changeset/virtual-route-external-physical.md b/.changeset/virtual-route-external-physical.md new file mode 100644 index 00000000000..ecc1aa3b994 --- /dev/null +++ b/.changeset/virtual-route-external-physical.md @@ -0,0 +1,6 @@ +--- +'@tanstack/router-generator': minor +'@tanstack/router-plugin': minor +--- + +feat: external directories in `physical()` virtual route mounts diff --git a/packages/router-generator/src/generator.ts b/packages/router-generator/src/generator.ts index e1b72327f2e..e4d16972a79 100644 --- a/packages/router-generator/src/generator.ts +++ b/packages/router-generator/src/generator.ts @@ -275,6 +275,10 @@ export class Generator { ) } + public getPhysicalDirectories(): Array { + return [...this.physicalDirectories] + } + public async run(event?: GeneratorEvent): Promise { if ( event && diff --git a/packages/router-generator/tests/generator.test.ts b/packages/router-generator/tests/generator.test.ts index 79f7d505b29..2b9524f597f 100644 --- a/packages/router-generator/tests/generator.test.ts +++ b/packages/router-generator/tests/generator.test.ts @@ -388,6 +388,39 @@ describe('generator works', async () => { }) }) + it('physical() accepts a path outside routesDirectory', async () => { + const folderName = 'virtual-physical-external-abs' + const dir = makeFolderDir(folderName) + const externalDir = path.join(dir, 'external-target') + const config = await setupConfig(folderName, { + virtualRouteConfig: rootRoute('__root.tsx', [ + index('index.tsx'), + physical('/external', externalDir), + ]), + }) + + const { routeNodes, physicalDirectories } = await virtualGetRouteNodes( + config, + dir, + { + indexTokenSegmentRegex: /^(?:index)$/, + routeTokenSegmentRegex: /^(?:route)$/, + }, + ) + + expect(physicalDirectories).toContain(externalDir) + expect(routeNodes.map((n) => n.routePath).sort()).toEqual([ + '/', + '/external/bar', + '/external/foo', + ]) + + const externalFoo = routeNodes.find((n) => n.routePath === '/external/foo')! + expect(path.resolve(config.routesDirectory, externalFoo.filePath)).toBe( + path.join(externalDir, 'foo.tsx'), + ) + }) + it.each(folderNames)( 'should create directory for routeTree if it does not exist', async () => { diff --git a/packages/router-generator/tests/generator/virtual-physical-external-abs/external-target/bar.tsx b/packages/router-generator/tests/generator/virtual-physical-external-abs/external-target/bar.tsx new file mode 100644 index 00000000000..e542318de3a --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-physical-external-abs/external-target/bar.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/external/bar')({ + component: () => 'bar', +}) diff --git a/packages/router-generator/tests/generator/virtual-physical-external-abs/external-target/foo.tsx b/packages/router-generator/tests/generator/virtual-physical-external-abs/external-target/foo.tsx new file mode 100644 index 00000000000..a03b3d56a03 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-physical-external-abs/external-target/foo.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/external/foo')({ + component: () => 'foo', +}) diff --git a/packages/router-generator/tests/generator/virtual-physical-external-abs/routeTree.snapshot.ts b/packages/router-generator/tests/generator/virtual-physical-external-abs/routeTree.snapshot.ts new file mode 100644 index 00000000000..d204c269b33 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-physical-external-abs/routeTree.snapshot.ts @@ -0,0 +1,59 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' + fileRoutesByTo: FileRoutesByTo + to: '/' + id: '__root__' | '/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/virtual-physical-external-abs/routes/__root.tsx b/packages/router-generator/tests/generator/virtual-physical-external-abs/routes/__root.tsx new file mode 100644 index 00000000000..f463b796b44 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-physical-external-abs/routes/__root.tsx @@ -0,0 +1,5 @@ +import { createRootRoute, Outlet } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: () => , +}) diff --git a/packages/router-generator/tests/generator/virtual-physical-external-abs/routes/index.tsx b/packages/router-generator/tests/generator/virtual-physical-external-abs/routes/index.tsx new file mode 100644 index 00000000000..0a298cf6cc7 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-physical-external-abs/routes/index.tsx @@ -0,0 +1,2 @@ +import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('/')({ component: () => 'home' }) diff --git a/packages/router-plugin/src/core/router-generator-plugin.ts b/packages/router-plugin/src/core/router-generator-plugin.ts index 055a1bc3c5b..a6aa2177543 100644 --- a/packages/router-plugin/src/core/router-generator-plugin.ts +++ b/packages/router-plugin/src/core/router-generator-plugin.ts @@ -1,4 +1,4 @@ -import { isAbsolute, join, normalize } from 'node:path' +import { isAbsolute, join, normalize, relative } from 'node:path' import { Generator, resolveConfigPath } from '@tanstack/router-generator' import { getConfig } from './config' @@ -9,6 +9,21 @@ import type { Config } from './config' const PLUGIN_NAME = 'unplugin:router-generator' +// Physical mounts that point outside `routesDirectory` — their files aren't +// covered by the bundler's own watcher. +function isInside(parent: string, child: string): boolean { + const rel = relative(parent, child) + return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)) +} +function getExternalPhysicalDirs( + generator: Generator, + routesDirectoryPath: string, +): Array { + return generator + .getPhysicalDirectories() + .filter((dir) => !isInside(routesDirectoryPath, dir)) +} + export const unpluginRouterGeneratorFactory: UnpluginFactory< Partial Config)> | undefined > = (options = {}) => { @@ -78,11 +93,30 @@ export const unpluginRouterGeneratorFactory: UnpluginFactory< initConfigAndGenerator({ root: config.root }) await generate() }, + configureServer(server) { + const external = getExternalPhysicalDirs( + generator, + getRoutesDirectoryPath(), + ) + if (external.length === 0) return + for (const dir of external) { + server.watcher.add(dir) + } + const onEvent = + (event: 'create' | 'update' | 'delete') => (file: string) => { + if (!external.some((dir) => isInside(dir, file))) return + void generate({ file, event }) + } + server.watcher.on('add', onEvent('create')) + server.watcher.on('change', onEvent('update')) + server.watcher.on('unlink', onEvent('delete')) + }, }, rspack(compiler) { initConfigAndGenerator() let handle: FSWatcher | null = null + let externalHandle: FSWatcher | null = null compiler.hooks.beforeRun.tapPromise(PLUGIN_NAME, () => generate()) @@ -98,19 +132,29 @@ export const unpluginRouterGeneratorFactory: UnpluginFactory< .watch(routesDirectoryPath, { ignoreInitial: true }) .on('add', (file) => generate({ file, event: 'create' })) + // External physical() mounts are outside rspack's file graph. + const external = getExternalPhysicalDirs(generator, routesDirectoryPath) + if (external.length > 0) { + externalHandle = chokidar + .watch(external, { ignoreInitial: true }) + .on('add', (file) => generate({ file, event: 'create' })) + .on('change', (file) => generate({ file, event: 'update' })) + .on('unlink', (file) => generate({ file, event: 'delete' })) + } + await generate() }) compiler.hooks.watchClose.tap(PLUGIN_NAME, async () => { - if (handle) { - await handle.close() - } + if (handle) await handle.close() + if (externalHandle) await externalHandle.close() }) }, webpack(compiler) { initConfigAndGenerator() let handle: FSWatcher | null = null + let externalHandle: FSWatcher | null = null compiler.hooks.beforeRun.tapPromise(PLUGIN_NAME, () => generate()) @@ -126,13 +170,22 @@ export const unpluginRouterGeneratorFactory: UnpluginFactory< .watch(routesDirectoryPath, { ignoreInitial: true }) .on('add', (file) => generate({ file, event: 'create' })) + // External physical() mounts are outside webpack's file graph. + const external = getExternalPhysicalDirs(generator, routesDirectoryPath) + if (external.length > 0) { + externalHandle = chokidar + .watch(external, { ignoreInitial: true }) + .on('add', (file) => generate({ file, event: 'create' })) + .on('change', (file) => generate({ file, event: 'update' })) + .on('unlink', (file) => generate({ file, event: 'delete' })) + } + await generate() }) compiler.hooks.watchClose.tap(PLUGIN_NAME, async () => { - if (handle) { - await handle.close() - } + if (handle) await handle.close() + if (externalHandle) await externalHandle.close() }) compiler.hooks.done.tap(PLUGIN_NAME, () => { diff --git a/packages/router-plugin/tests/router-generator-plugin-watcher.test.ts b/packages/router-plugin/tests/router-generator-plugin-watcher.test.ts new file mode 100644 index 00000000000..1fce84bb633 --- /dev/null +++ b/packages/router-plugin/tests/router-generator-plugin-watcher.test.ts @@ -0,0 +1,121 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import { afterEach, beforeEach, describe, it } from 'vitest' +import { createServer } from 'vite' +import type { ViteDevServer } from 'vite' + +import { physical, rootRoute } from '@tanstack/virtual-file-routes' +import { tanstackRouterGenerator } from '../src/vite' + +const ROOT_ROUTE = `import { createRootRoute, Outlet } from '@tanstack/react-router' +export const Route = createRootRoute({ component: () => null }) +` + +const makeRouteFile = (routePath: string) => + `import { createFileRoute } from '@tanstack/react-router' +export const Route = createFileRoute('${routePath}')({ component: () => null }) +` + +async function waitUntil( + condition: () => boolean | Promise, + { timeoutMs = 10_000, intervalMs = 50 } = {}, +) { + const start = Date.now() + while (Date.now() - start < timeoutMs) { + if (await condition()) return + await new Promise((r) => setTimeout(r, intervalMs)) + } + throw new Error(`Timed out after ${timeoutMs}ms`) +} + +async function routeTreeIncludes(generatedRouteTree: string, match: string) { + try { + const text = await readFile(generatedRouteTree, 'utf-8') + return text.includes(match) + } catch { + return false + } +} + +describe('router-generator-plugin vite watcher', () => { + let fixtureDir = '' + let externalDir = '' + let routesDir = '' + let generatedRouteTree = '' + let server: ViteDevServer | undefined + + beforeEach(async () => { + // Use a directory within the package to avoid cross-device rename errors: + // the generator writes temp files to .tanstack/tmp/ then does an atomic + // rename(), which fails with EXDEV when tmpdir() is on another device. + fixtureDir = await mkdtemp( + path.join(path.dirname(fileURLToPath(import.meta.url)), 'tmp-watcher-'), + ) + routesDir = path.join(fixtureDir, 'routes') + externalDir = path.join(fixtureDir, 'external') + generatedRouteTree = path.join(fixtureDir, 'routeTree.gen.ts') + + await mkdir(routesDir, { recursive: true }) + await mkdir(externalDir, { recursive: true }) + await writeFile(path.join(routesDir, '__root.tsx'), ROOT_ROUTE) + await writeFile( + path.join(externalDir, 'alpha.tsx'), + makeRouteFile('/ext/alpha'), + ) + }) + + afterEach(async () => { + if (server) { + await server.close() + server = undefined + } + await rm(fixtureDir, { recursive: true, force: true }) + }) + + it( + 'regenerates routeTree on add/remove in an external physical mount', + { timeout: 30_000 }, + async () => { + server = await createServer({ + root: fixtureDir, + configFile: false, + logLevel: 'silent', + appType: 'custom', + server: { middlewareMode: true, watch: {} }, + plugins: [ + tanstackRouterGenerator({ + routesDirectory: routesDir, + generatedRouteTree, + virtualRouteConfig: rootRoute('__root.tsx', [ + physical('/ext', externalDir), + ]), + disableLogging: true, + }), + ], + }) + + await waitUntil(() => + routeTreeIncludes(generatedRouteTree, "'/ext/alpha'"), + ) + + // Short settle after each fs mutation — the plugin debounces and the + // generator may run multiple passes for a single chokidar burst. + const settle = () => new Promise((r) => setTimeout(r, 500)) + + const betaPath = path.join(externalDir, 'beta.tsx') + await writeFile(betaPath, makeRouteFile('/ext/beta')) + await settle() + await waitUntil(() => + routeTreeIncludes(generatedRouteTree, "'/ext/beta'"), + ) + + await rm(betaPath) + await settle() + await waitUntil( + async () => + !(await routeTreeIncludes(generatedRouteTree, "'/ext/beta'")), + ) + }, + ) +})