Skip to content

Commit e1f0cd5

Browse files
committed
fix(router): avoid route watcher rebuild churn
1 parent 42e904c commit e1f0cd5

50 files changed

Lines changed: 456 additions & 73 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/onestack.dev/app/routes.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { OneRouter } from 'one'
66
declare module 'one' {
77
export namespace OneRouter {
88
export interface __routes<T extends string = string> extends Record<string, unknown> {
9-
StaticRoutes:
9+
StaticRoutes:
1010
| `/`
1111
| `/(content)`
1212
| `/(content)/blog`

examples/one-basic/app/routes.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { OneRouter } from 'one'
66
declare module 'one' {
77
export namespace OneRouter {
88
export interface __routes<T extends string = string> extends Record<string, unknown> {
9-
StaticRoutes:
9+
StaticRoutes:
1010
| `/`
1111
| `/_sitemap`
1212
| `/tabs`
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
mkdirSync,
3+
mkdtempSync,
4+
rmSync,
5+
statSync,
6+
utimesSync,
7+
writeFileSync,
8+
} from 'node:fs'
9+
import { tmpdir } from 'node:os'
10+
import path from 'node:path'
11+
import { afterEach, describe, expect, it } from 'vitest'
12+
import { generateRouteTypes } from './generateRouteTypes'
13+
14+
describe(generateRouteTypes, () => {
15+
const originalCwd = process.cwd()
16+
let tempRoot: string | undefined
17+
18+
afterEach(() => {
19+
process.chdir(originalCwd)
20+
21+
if (tempRoot) {
22+
rmSync(tempRoot, { recursive: true, force: true })
23+
tempRoot = undefined
24+
}
25+
})
26+
27+
it('does not rewrite routes declarations when contents are unchanged', async () => {
28+
tempRoot = mkdtempSync(path.join(tmpdir(), 'one-routes-types-'))
29+
const appDir = path.join(tempRoot, 'app')
30+
mkdirSync(appDir)
31+
writeFileSync(
32+
path.join(appDir, 'index+ssg.tsx'),
33+
'export default function Index() { return null }\n'
34+
)
35+
writeFileSync(
36+
path.join(appDir, '[slug]+ssg.tsx'),
37+
'export default function Slug() { return null }\n'
38+
)
39+
40+
process.chdir(tempRoot)
41+
42+
const outFile = path.join('app', 'routes.d.ts')
43+
await generateRouteTypes(outFile, 'app')
44+
45+
const oldDate = new Date('2001-01-01T00:00:00.000Z')
46+
utimesSync(outFile, oldDate, oldDate)
47+
const previousMtimeMs = statSync(outFile).mtimeMs
48+
49+
await generateRouteTypes(outFile, 'app')
50+
51+
expect(statSync(outFile).mtimeMs).toBe(previousMtimeMs)
52+
})
53+
})

packages/one/src/typed-routes/generateRouteTypes.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { writeFile } from 'node:fs/promises'
1+
import { readFile, writeFile } from 'node:fs/promises'
22
import { dirname, join } from 'node:path'
33
import FSExtra from 'fs-extra'
44
import micromatch from 'micromatch'
@@ -33,8 +33,19 @@ export async function generateRouteTypes(
3333
const context = globbedRoutesToRouteContext(routes, routerRoot)
3434
const declarations = getTypedRoutesDeclarationFile(context)
3535
const outDir = dirname(outFile)
36-
await FSExtra.ensureDir(outDir)
37-
await writeFile(outFile, declarations)
36+
let currentDeclarations: string | undefined
37+
try {
38+
currentDeclarations = await readFile(outFile, 'utf8')
39+
} catch (error) {
40+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
41+
throw error
42+
}
43+
}
44+
45+
if (currentDeclarations !== declarations) {
46+
await FSExtra.ensureDir(outDir)
47+
await writeFile(outFile, declarations)
48+
}
3849

3950
// If experimental.typedRoutesGeneration is enabled, inject helpers into route files
4051
if (typedRoutesMode) {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { One } from '../vite/types'
3+
import { getTypedRoutesDeclarationFile } from './getTypedRoutesDeclarationFile'
4+
5+
function createRouteContext(paths: string[]) {
6+
const context = (() => ({ default() {} })) as unknown as One.RouteContext
7+
Object.defineProperty(context, 'keys', {
8+
value: () => paths,
9+
})
10+
return context
11+
}
12+
13+
describe(getTypedRoutesDeclarationFile, () => {
14+
it('does not emit trailing whitespace for multi-line route unions', () => {
15+
const declaration = getTypedRoutesDeclarationFile(
16+
createRouteContext(['./index+ssg.tsx', './about+ssg.tsx', './[slug]+ssg.tsx'])
17+
)
18+
19+
expect(declaration).toContain(' StaticRoutes:\n | `/`')
20+
expect(declaration).not.toMatch(/[ \t]+$/m)
21+
})
22+
})

packages/one/src/typed-routes/getTypedRoutesDeclarationFile.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ import type { OneRouter } from 'one'
3636
declare module 'one' {
3737
export namespace OneRouter {
3838
export interface __routes<T extends string = string> extends Record<string, unknown> {
39-
StaticRoutes: ${setToUnionType(staticRoutes)}
40-
DynamicRoutes: ${setToUnionType(dynamicRoutes)}
41-
DynamicRouteTemplate: ${setToUnionType(dynamicRouteContextKeys)}
39+
StaticRoutes:${setToUnionType(staticRoutes)}
40+
DynamicRoutes:${setToUnionType(dynamicRoutes)}
41+
DynamicRouteTemplate:${setToUnionType(dynamicRouteContextKeys)}
4242
IsTyped: true
4343
${hasRoutes ? `RouteTypes: ${generateRouteTypesMap(dynamicRouteContextKeys)}` : ''}
4444
}
@@ -189,9 +189,9 @@ function addRouteNode(
189189
* Formats with one route per line for cleaner git diffs
190190
*/
191191
const setToUnionType = <T>(set: Set<T>) => {
192-
if (set.size === 0) return 'never'
192+
if (set.size === 0) return ' never'
193193
const sorted = [...set].sort()
194-
if (sorted.length === 1) return `\`${sorted[0]}\``
194+
if (sorted.length === 1) return ` \`${sorted[0]}\``
195195
// format as multi-line union for cleaner diffs
196196
return '\n | ' + sorted.map((s) => `\`${s}\``).join('\n | ')
197197
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import path from 'node:path'
2+
import { describe, expect, it } from 'vitest'
3+
import {
4+
isPathInsideDirectory,
5+
isRouteFilePath,
6+
isRouteFileWatchEvent,
7+
} from './routeFileWatch'
8+
9+
describe(isRouteFilePath, () => {
10+
it('matches route source files', () => {
11+
expect(isRouteFilePath('/project/app/index.ts')).toBe(true)
12+
expect(isRouteFilePath('/project/app/index.tsx')).toBe(true)
13+
expect(isRouteFilePath('/project/app/index.js')).toBe(true)
14+
expect(isRouteFilePath('/project/app/index.jsx')).toBe(true)
15+
})
16+
17+
it('ignores non-route files', () => {
18+
expect(isRouteFilePath('/project/app/tamagui.generated.css')).toBe(false)
19+
expect(isRouteFilePath('/project/app/routes.d.ts')).toBe(false)
20+
})
21+
})
22+
23+
describe(isPathInsideDirectory, () => {
24+
it('only matches real descendants of the router root', () => {
25+
const routerRoot = path.resolve('/project/app')
26+
27+
expect(isPathInsideDirectory('/project/app/index.tsx', routerRoot)).toBe(true)
28+
expect(isPathInsideDirectory('/project/app-copy/index.tsx', routerRoot)).toBe(false)
29+
expect(isPathInsideDirectory('/project/app', routerRoot)).toBe(false)
30+
})
31+
})
32+
33+
describe(isRouteFileWatchEvent, () => {
34+
const routerRoot = path.resolve('/project/app')
35+
36+
it('matches route file add and delete events', () => {
37+
expect(
38+
isRouteFileWatchEvent({
39+
event: 'add',
40+
filePath: '/project/app/index.tsx',
41+
routerRoot,
42+
})
43+
).toBe(true)
44+
expect(
45+
isRouteFileWatchEvent({
46+
event: 'delete',
47+
filePath: '/project/app/nested/page.jsx',
48+
routerRoot,
49+
})
50+
).toBe(true)
51+
expect(
52+
isRouteFileWatchEvent({
53+
event: 'unlink',
54+
filePath: '/project/app/nested/page.jsx',
55+
routerRoot,
56+
})
57+
).toBe(true)
58+
})
59+
60+
it('ignores non-route file add and delete events', () => {
61+
expect(
62+
isRouteFileWatchEvent({
63+
event: 'add',
64+
filePath: '/project/app/tamagui.generated.css',
65+
routerRoot,
66+
})
67+
).toBe(false)
68+
expect(
69+
isRouteFileWatchEvent({
70+
event: 'delete',
71+
filePath: '/project/app/routes.d.ts',
72+
routerRoot,
73+
})
74+
).toBe(false)
75+
})
76+
77+
it('only matches change events when requested', () => {
78+
expect(
79+
isRouteFileWatchEvent({
80+
event: 'change',
81+
filePath: '/project/app/index.tsx',
82+
routerRoot,
83+
})
84+
).toBe(false)
85+
expect(
86+
isRouteFileWatchEvent({
87+
event: 'change',
88+
filePath: '/project/app/index.tsx',
89+
routerRoot,
90+
includeChangeEvents: true,
91+
})
92+
).toBe(true)
93+
})
94+
})
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import path from 'node:path'
2+
3+
const routeFileExtensionRe = /\.[jt]sx?$/
4+
5+
export function isRouteFilePath(filePath: string) {
6+
return routeFileExtensionRe.test(filePath) && !filePath.endsWith('.d.ts')
7+
}
8+
9+
export function isPathInsideDirectory(filePath: string, directory: string) {
10+
const relativePath = path.relative(path.resolve(directory), path.resolve(filePath))
11+
return (
12+
relativePath !== '' &&
13+
!relativePath.startsWith('..') &&
14+
!path.isAbsolute(relativePath)
15+
)
16+
}
17+
18+
export function isRouteFileWatchEvent({
19+
event,
20+
filePath,
21+
routerRoot,
22+
includeChangeEvents = false,
23+
}: {
24+
event: string
25+
filePath: string
26+
routerRoot: string
27+
includeChangeEvents?: boolean
28+
}) {
29+
const isRouteFileEvent =
30+
event === 'add' ||
31+
event === 'delete' ||
32+
event === 'unlink' ||
33+
(includeChangeEvents && event === 'change')
34+
35+
return (
36+
isRouteFileEvent &&
37+
isPathInsideDirectory(filePath, routerRoot) &&
38+
isRouteFilePath(filePath)
39+
)
40+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import path from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('vite', async () => {
7+
const actual = await vi.importActual<typeof import('vite')>('vite')
8+
return {
9+
...actual,
10+
createServerModuleRunner: vi.fn(() => ({
11+
clearCache: vi.fn(),
12+
import: vi.fn(),
13+
})),
14+
}
15+
})
16+
17+
describe('createFileSystemRouterPlugin', () => {
18+
const previousIsVxrnCli = process.env.IS_VXRN_CLI
19+
let previousVxrnPluginConfig: unknown
20+
let tempRoot: string | undefined
21+
22+
beforeEach(() => {
23+
previousVxrnPluginConfig = (globalThis as any).__vxrnPluginConfig__
24+
})
25+
26+
afterEach(() => {
27+
if (tempRoot) {
28+
rmSync(tempRoot, { recursive: true, force: true })
29+
tempRoot = undefined
30+
}
31+
32+
if (previousIsVxrnCli === undefined) {
33+
delete process.env.IS_VXRN_CLI
34+
} else {
35+
process.env.IS_VXRN_CLI = previousIsVxrnCli
36+
}
37+
38+
if (previousVxrnPluginConfig === undefined) {
39+
delete (globalThis as any).__vxrnPluginConfig__
40+
} else {
41+
;(globalThis as any).__vxrnPluginConfig__ = previousVxrnPluginConfig
42+
}
43+
vi.restoreAllMocks()
44+
})
45+
46+
it('keeps route watcher rebuild errors handled', async () => {
47+
process.env.IS_VXRN_CLI = '1'
48+
tempRoot = mkdtempSync(path.join(tmpdir(), 'one-router-watch-'))
49+
const appDir = path.join(tempRoot, 'app')
50+
writeFileSync(path.join(tempRoot, 'package.json'), '{}\n')
51+
mkdirSync(appDir)
52+
writeFileSync(
53+
path.join(appDir, 'index.tsx'),
54+
'export default function Index() { return null }\n'
55+
)
56+
57+
;(globalThis as any).__vxrnPluginConfig__ = {
58+
web: {
59+
defaultRenderMode: 'ssg',
60+
},
61+
}
62+
63+
const { createFileSystemRouterPlugin } = await import('./fileSystemRouterPlugin')
64+
const plugin = createFileSystemRouterPlugin({
65+
router: {
66+
root: appDir,
67+
},
68+
})
69+
70+
let watcherListener:
71+
| ((event: string, changedPath: string) => void | Promise<void>)
72+
| undefined
73+
const server = {
74+
environments: {
75+
ssr: {},
76+
},
77+
watcher: {
78+
addListener: vi.fn((event: string, listener: typeof watcherListener) => {
79+
if (event === 'all') {
80+
watcherListener = listener
81+
}
82+
}),
83+
on: vi.fn(),
84+
},
85+
}
86+
87+
;(plugin as any).configureServer(server)
88+
expect(watcherListener).toBeDefined()
89+
90+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
91+
delete (globalThis as any).__vxrnPluginConfig__
92+
93+
if (!watcherListener) {
94+
throw new Error('Expected route watcher listener to be registered')
95+
}
96+
97+
await expect(
98+
Promise.resolve(watcherListener('add', path.join(appDir, 'new-route.tsx')))
99+
).resolves.toBeUndefined()
100+
101+
expect(warn).toHaveBeenCalledWith(
102+
expect.stringContaining('[one] Failed to rebuild routes'),
103+
expect.any(Error)
104+
)
105+
})
106+
})

0 commit comments

Comments
 (0)