-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
feat: external directories in physical() virtual route mounts
#7196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ce38da8
8cdb7e4
aebbbc0
4c4f77e
b36e09b
c2e9232
c207ebb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| --- | ||
| '@tanstack/router-generator': minor | ||
| '@tanstack/router-plugin': minor | ||
| --- | ||
|
|
||
| feat: external directories in `physical()` virtual route mounts |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import { createFileRoute } from '@tanstack/react-router' | ||
| export const Route = createFileRoute('/external/bar')({ | ||
| component: () => 'bar', | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| import { createFileRoute } from '@tanstack/react-router' | ||
| export const Route = createFileRoute('/external/foo')({ | ||
| component: () => 'foo', | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<FileRouteTypes>() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import { createRootRoute, Outlet } from '@tanstack/react-router' | ||
|
|
||
| export const Route = createRootRoute({ | ||
| component: () => <Outlet />, | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| import { createFileRoute } from '@tanstack/react-router' | ||
| export const Route = createFileRoute('/')({ component: () => 'home' }) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<string> { | ||||||||||||||||||||||||
| return generator | ||||||||||||||||||||||||
| .getPhysicalDirectories() | ||||||||||||||||||||||||
| .filter((dir) => !isInside(routesDirectoryPath, dir)) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export const unpluginRouterGeneratorFactory: UnpluginFactory< | ||||||||||||||||||||||||
| Partial<Config | (() => 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() | ||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
|
Comment on lines
186
to
189
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same Identical issue: the synchronous Proposed fix compiler.hooks.watchClose.tap(PLUGIN_NAME, async () => {
- if (handle) await handle.close()
- if (externalHandle) await externalHandle.close()
+ if (handle) void handle.close()
+ if (externalHandle) void externalHandle.close()
})📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| compiler.hooks.done.tap(PLUGIN_NAME, () => { | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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' | ||||||||||||||||||||||
|
Comment on lines
+6
to
+9
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix import ordering to satisfy Line 6 currently violates the configured import order ( Proposed diff 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'
+import type { ViteDevServer } from 'vite'📝 Committable suggestion
Suggested change
🧰 Tools🪛 ESLint[error] 6-6: (import/order) 🤖 Prompt for AI Agents |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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<boolean>, | ||||||||||||||||||||||
| { 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 () => { | ||||||||||||||||||||||
|
Comment on lines
+76
to
+79
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test timeout is lower than worst-case polling duration. This test can run longer than 20s ( Proposed diff it(
'regenerates routeTree on add/remove in an external physical mount',
- { timeout: 20_000 },
+ { timeout: 35_000 },
async () => {📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||
| 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'")), | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| }) | ||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
asynccallback in synchronoustaphook won't await cleanup.The
watchClosehook uses.tap()which is synchronous and won't wait for the async callback's promises. Theasynckeyword here is misleading—handle.close()returns a Promise that won't be awaited.This could cause watchers to not be fully closed during rapid dev server restarts.
Proposed fix: use fire-and-forget or void the promises explicitly
compiler.hooks.watchClose.tap(PLUGIN_NAME, async () => { - if (handle) await handle.close() - if (externalHandle) await externalHandle.close() + if (handle) void handle.close() + if (externalHandle) void externalHandle.close() })Or alternatively, if cleanup needs to be awaited, investigate if rspack supports an async shutdown hook.
📝 Committable suggestion
🤖 Prompt for AI Agents