From aee9b7a4084ae267eb4adc4622a19743e80c9a8b Mon Sep 17 00:00:00 2001 From: libondev Date: Tue, 10 Jun 2025 14:19:34 +0800 Subject: [PATCH 1/9] feat: add auto-hmr-plugin and autoHmr option --- src/auto-hmr/index.ts | 101 ++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 9 ++++ src/options.ts | 6 +++ 3 files changed, 116 insertions(+) create mode 100644 src/auto-hmr/index.ts diff --git a/src/auto-hmr/index.ts b/src/auto-hmr/index.ts new file mode 100644 index 000000000..4e8b08e75 --- /dev/null +++ b/src/auto-hmr/index.ts @@ -0,0 +1,101 @@ +import type { UnpluginOptions } from 'unplugin' +import type { VariableDeclarator, ImportDeclaration } from 'estree' + +function nameFromDeclaration(node?: VariableDeclarator) { + return node?.id.type === 'Identifier' ? node.id.name : '' +} + +function getRouterDeclaration(nodes?: VariableDeclarator[]) { + return nodes?.find( + (x) => + x.init?.type === 'CallExpression' && + x.init.callee.type === 'Identifier' && + x.init.callee.name === 'createRouter' + ) +} + +function getHandleHotUpdateDeclaration(node?: ImportDeclaration, modulePath?: string) { + return ( + node?.type === 'ImportDeclaration' && + node.source.value === modulePath && + node.specifiers.some( + (x) => + x.type === 'ImportSpecifier' && + x.imported.type === 'Identifier' && + x.imported.name === 'handleHotUpdate' + ) + ) +} + +interface AutoHmrPluginOptions { + modulePath: string +} + +export function createAutoHmrPlugin({ modulePath }: AutoHmrPluginOptions): UnpluginOptions { + const handleHotUpdateCallRegex = /handleHotUpdate\([\s\S]*?\)/ + + return { + name: 'unplugin-vue-router-auto-hmr', + enforce: 'post', + + transform(code, id) { + if (id.startsWith('\x00')) return + + // If you don't use automatically generated routes, + // maybe it will be meaningless to deal with hmr? + if (!code.includes('createRouter(') && !code.includes(modulePath)) { + return + } + + const ast = this.parse(code) + + let isImported: boolean = false + let routerName: string | undefined + let routerDeclaration: VariableDeclarator | undefined + + // @ts-expect-error + for (const node of ast.body) { + if ( + node.type === 'ExportNamedDeclaration' || + node.type === 'VariableDeclaration' + ) { + if (!routerName) { + routerDeclaration = getRouterDeclaration(node.type === 'VariableDeclaration' + ? node.declarations + : node.declaration?.type === 'VariableDeclaration' + ? node.declaration?.declarations + : undefined) + + routerName = nameFromDeclaration(routerDeclaration) + } + } else if (node.type === 'ImportDeclaration') { + isImported ||= getHandleHotUpdateDeclaration(node, modulePath) + } + } + + if (routerName) { + const isHandleHotUpdateCalled = handleHotUpdateCallRegex.test(code) + + const handleHotUpdateCode = [code] + + // add import if not imported + if (!isImported) { + handleHotUpdateCode.unshift( + `import { handleHotUpdate } from '${modulePath}'` + ) + } + + // add handleHotUpdate call if not called + if (!isHandleHotUpdateCalled) { + handleHotUpdateCode.push(`handleHotUpdate(${routerName})`) + } + + return { + code: handleHotUpdateCode.join('\n') + } + } + + return + } + } +} diff --git a/src/index.ts b/src/index.ts index d51df9472..bc5346561 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { join } from 'pathe' import { appendExtensionListToPattern } from './core/utils' import { MACRO_DEFINE_PAGE_QUERY } from './core/definePage' import { createAutoExportPlugin } from './data-loaders/auto-exports' +import { createAutoHmrPlugin } from './auto-hmr' export type * from './types' @@ -202,6 +203,14 @@ export default createUnplugin((opt = {}, _meta) => { ) } + if (options.autoHmr) { + plugins.push( + createAutoHmrPlugin({ + modulePath: MODULE_ROUTES_PATH, + }) + ) + } + return plugins }) diff --git a/src/options.ts b/src/options.ts index 7fe1f7e0f..69e79ee0d 100644 --- a/src/options.ts +++ b/src/options.ts @@ -213,6 +213,12 @@ export interface Options { */ watch?: boolean + /** + * Whether to enable auto HMR for Vue Router. + * @default `false` + */ + autoHmr?: boolean + /** * Experimental options. **Warning**: these can change or be removed at any time, even it patch releases. Keep an eye * on the Changelog. From 7abc277ad2d07310cf6e428d71d5d22eb37bf1d9 Mon Sep 17 00:00:00 2001 From: Libon Date: Tue, 10 Jun 2025 14:30:51 +0800 Subject: [PATCH 2/9] chore: improve the handling of type errors. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/auto-hmr/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/auto-hmr/index.ts b/src/auto-hmr/index.ts index 4e8b08e75..16f55a37f 100644 --- a/src/auto-hmr/index.ts +++ b/src/auto-hmr/index.ts @@ -53,7 +53,9 @@ export function createAutoHmrPlugin({ modulePath }: AutoHmrPluginOptions): Unplu let routerName: string | undefined let routerDeclaration: VariableDeclarator | undefined - // @ts-expect-error + for (const node of ast.body as any[]) { + // …rest of loop body… + } for (const node of ast.body) { if ( node.type === 'ExportNamedDeclaration' || From ea89aa8a286179489644f45ed1fe22df28afac19 Mon Sep 17 00:00:00 2001 From: libondev Date: Tue, 10 Jun 2025 14:31:50 +0800 Subject: [PATCH 3/9] Revert "chore: improve the handling of type errors." This reverts commit 7abc277ad2d07310cf6e428d71d5d22eb37bf1d9. --- src/auto-hmr/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/auto-hmr/index.ts b/src/auto-hmr/index.ts index 16f55a37f..4e8b08e75 100644 --- a/src/auto-hmr/index.ts +++ b/src/auto-hmr/index.ts @@ -53,9 +53,7 @@ export function createAutoHmrPlugin({ modulePath }: AutoHmrPluginOptions): Unplu let routerName: string | undefined let routerDeclaration: VariableDeclarator | undefined - for (const node of ast.body as any[]) { - // …rest of loop body… - } + // @ts-expect-error for (const node of ast.body) { if ( node.type === 'ExportNamedDeclaration' || From 9fdf3397e97947890f1288c15856223b1be4e25f Mon Sep 17 00:00:00 2001 From: libondev Date: Tue, 10 Jun 2025 14:41:03 +0800 Subject: [PATCH 4/9] refactor(auto-hmr): Judging whether to call handleHotUpdate based on ast --- src/auto-hmr/index.ts | 52 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/src/auto-hmr/index.ts b/src/auto-hmr/index.ts index 4e8b08e75..61ac68b09 100644 --- a/src/auto-hmr/index.ts +++ b/src/auto-hmr/index.ts @@ -1,5 +1,6 @@ import type { UnpluginOptions } from 'unplugin' import type { VariableDeclarator, ImportDeclaration } from 'estree' +import type { AstNode } from 'rollup' function nameFromDeclaration(node?: VariableDeclarator) { return node?.id.type === 'Identifier' ? node.id.name : '' @@ -27,13 +28,54 @@ function getHandleHotUpdateDeclaration(node?: ImportDeclaration, modulePath?: st ) } +function hasHandleHotUpdateCall(ast: AstNode) { + function traverse(node: any) { + if (!node) return false; + + if ( + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'handleHotUpdate' + ) { + return true; + } + + // e.g.: autoRouter.handleHotUpdate() + if ( + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'handleHotUpdate' + ) { + return true; + } + + if (typeof node !== 'object') return false; + + for (const key in node) { + if (key === 'type' || key === 'loc' || key === 'range') continue; + + const child = node[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (traverse(item)) return true; + } + } else if (typeof child === 'object' && child !== null) { + if (traverse(child)) return true; + } + } + + return false; + } + + return traverse(ast); +} + interface AutoHmrPluginOptions { modulePath: string } export function createAutoHmrPlugin({ modulePath }: AutoHmrPluginOptions): UnpluginOptions { - const handleHotUpdateCallRegex = /handleHotUpdate\([\s\S]*?\)/ - return { name: 'unplugin-vue-router-auto-hmr', enforce: 'post', @@ -49,7 +91,7 @@ export function createAutoHmrPlugin({ modulePath }: AutoHmrPluginOptions): Unplu const ast = this.parse(code) - let isImported: boolean = false + let isImported = false let routerName: string | undefined let routerDeclaration: VariableDeclarator | undefined @@ -74,7 +116,7 @@ export function createAutoHmrPlugin({ modulePath }: AutoHmrPluginOptions): Unplu } if (routerName) { - const isHandleHotUpdateCalled = handleHotUpdateCallRegex.test(code) + const isCalledHandleHotUpdate = hasHandleHotUpdateCall(ast) const handleHotUpdateCode = [code] @@ -86,7 +128,7 @@ export function createAutoHmrPlugin({ modulePath }: AutoHmrPluginOptions): Unplu } // add handleHotUpdate call if not called - if (!isHandleHotUpdateCalled) { + if (!isCalledHandleHotUpdate) { handleHotUpdateCode.push(`handleHotUpdate(${routerName})`) } From 0c48022c9c5df26f46e65cd7628724382514fc0f Mon Sep 17 00:00:00 2001 From: libondev Date: Fri, 21 Nov 2025 11:53:46 +0800 Subject: [PATCH 5/9] chore: move ast function to specific file --- src/auto-hmr/ast.ts | 71 +++++++++++++++++++++++++++++++++ src/auto-hmr/index.ts | 92 ++++++++----------------------------------- 2 files changed, 87 insertions(+), 76 deletions(-) create mode 100644 src/auto-hmr/ast.ts diff --git a/src/auto-hmr/ast.ts b/src/auto-hmr/ast.ts new file mode 100644 index 000000000..af4c3671a --- /dev/null +++ b/src/auto-hmr/ast.ts @@ -0,0 +1,71 @@ +import type { AstNode } from 'rollup' +import type { VariableDeclarator, ImportDeclaration } from 'estree' + +export function nameFromDeclaration(node?: VariableDeclarator) { + return node?.id.type === 'Identifier' ? node.id.name : '' +} + +export function getRouterDeclaration(nodes?: VariableDeclarator[]) { + return nodes?.find( + (x) => + x.init?.type === 'CallExpression' && + x.init.callee.type === 'Identifier' && + x.init.callee.name === 'createRouter' + ) +} + +export function getHandleHotUpdateDeclaration(node?: ImportDeclaration, modulePath?: string) { + return ( + node?.type === 'ImportDeclaration' && + node.source.value === modulePath && + node.specifiers.some( + (x) => + x.type === 'ImportSpecifier' && + x.imported.type === 'Identifier' && + x.imported.name === 'handleHotUpdate' + ) + ) +} + +export function hasHandleHotUpdateCall(ast: AstNode) { + function traverse(node: any) { + if (!node) return false; + + if ( + node.type === 'CallExpression' && + node.callee.type === 'Identifier' && + node.callee.name === 'handleHotUpdate' + ) { + return true; + } + + // e.g.: autoRouter.handleHotUpdate() + if ( + node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'handleHotUpdate' + ) { + return true; + } + + if (typeof node !== 'object') return false; + + for (const key in node) { + if (key === 'type' || key === 'loc' || key === 'range') continue; + + const child = node[key]; + if (Array.isArray(child)) { + for (const item of child) { + if (traverse(item)) return true; + } + } else if (typeof child === 'object' && child !== null) { + if (traverse(child)) return true; + } + } + + return false; + } + + return traverse(ast); +} diff --git a/src/auto-hmr/index.ts b/src/auto-hmr/index.ts index 61ac68b09..9bd9ac13d 100644 --- a/src/auto-hmr/index.ts +++ b/src/auto-hmr/index.ts @@ -1,81 +1,19 @@ import type { UnpluginOptions } from 'unplugin' -import type { VariableDeclarator, ImportDeclaration } from 'estree' -import type { AstNode } from 'rollup' - -function nameFromDeclaration(node?: VariableDeclarator) { - return node?.id.type === 'Identifier' ? node.id.name : '' -} - -function getRouterDeclaration(nodes?: VariableDeclarator[]) { - return nodes?.find( - (x) => - x.init?.type === 'CallExpression' && - x.init.callee.type === 'Identifier' && - x.init.callee.name === 'createRouter' - ) -} - -function getHandleHotUpdateDeclaration(node?: ImportDeclaration, modulePath?: string) { - return ( - node?.type === 'ImportDeclaration' && - node.source.value === modulePath && - node.specifiers.some( - (x) => - x.type === 'ImportSpecifier' && - x.imported.type === 'Identifier' && - x.imported.name === 'handleHotUpdate' - ) - ) -} - -function hasHandleHotUpdateCall(ast: AstNode) { - function traverse(node: any) { - if (!node) return false; - - if ( - node.type === 'CallExpression' && - node.callee.type === 'Identifier' && - node.callee.name === 'handleHotUpdate' - ) { - return true; - } - - // e.g.: autoRouter.handleHotUpdate() - if ( - node.type === 'CallExpression' && - node.callee.type === 'MemberExpression' && - node.callee.property.type === 'Identifier' && - node.callee.property.name === 'handleHotUpdate' - ) { - return true; - } - - if (typeof node !== 'object') return false; - - for (const key in node) { - if (key === 'type' || key === 'loc' || key === 'range') continue; - - const child = node[key]; - if (Array.isArray(child)) { - for (const item of child) { - if (traverse(item)) return true; - } - } else if (typeof child === 'object' && child !== null) { - if (traverse(child)) return true; - } - } - - return false; - } - - return traverse(ast); -} +import type { VariableDeclarator } from 'estree' +import { + nameFromDeclaration, + getRouterDeclaration, + getHandleHotUpdateDeclaration, + hasHandleHotUpdateCall, +} from './ast' interface AutoHmrPluginOptions { modulePath: string } -export function createAutoHmrPlugin({ modulePath }: AutoHmrPluginOptions): UnpluginOptions { +export function createAutoHmrPlugin({ + modulePath, +}: AutoHmrPluginOptions): UnpluginOptions { return { name: 'unplugin-vue-router-auto-hmr', enforce: 'post', @@ -102,11 +40,13 @@ export function createAutoHmrPlugin({ modulePath }: AutoHmrPluginOptions): Unplu node.type === 'VariableDeclaration' ) { if (!routerName) { - routerDeclaration = getRouterDeclaration(node.type === 'VariableDeclaration' + routerDeclaration = getRouterDeclaration( + node.type === 'VariableDeclaration' ? node.declarations : node.declaration?.type === 'VariableDeclaration' ? node.declaration?.declarations - : undefined) + : undefined + ) routerName = nameFromDeclaration(routerDeclaration) } @@ -133,11 +73,11 @@ export function createAutoHmrPlugin({ modulePath }: AutoHmrPluginOptions): Unplu } return { - code: handleHotUpdateCode.join('\n') + code: handleHotUpdateCode.join('\n'), } } return - } + }, } } From 97c5ba5116dbac4ecc6e558554e5854d485e2bcd Mon Sep 17 00:00:00 2001 From: libondev Date: Fri, 21 Nov 2025 11:59:20 +0800 Subject: [PATCH 6/9] feat: use regular expressions to determine whether `createRouter` is called --- src/auto-hmr/ast.ts | 3 ++- src/auto-hmr/index.ts | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/auto-hmr/ast.ts b/src/auto-hmr/ast.ts index af4c3671a..31680802b 100644 --- a/src/auto-hmr/ast.ts +++ b/src/auto-hmr/ast.ts @@ -10,7 +10,8 @@ export function getRouterDeclaration(nodes?: VariableDeclarator[]) { (x) => x.init?.type === 'CallExpression' && x.init.callee.type === 'Identifier' && - x.init.callee.name === 'createRouter' + (x.init.callee.name === 'createRouter' || + x.init.callee.name === 'experimental_createRouter') ) } diff --git a/src/auto-hmr/index.ts b/src/auto-hmr/index.ts index 9bd9ac13d..928323154 100644 --- a/src/auto-hmr/index.ts +++ b/src/auto-hmr/index.ts @@ -14,6 +14,9 @@ interface AutoHmrPluginOptions { export function createAutoHmrPlugin({ modulePath, }: AutoHmrPluginOptions): UnpluginOptions { + const hasCreateRouterFnCallRegex = + /\w+\s*=\s*(?:experimental_)?createRouter\(/ + return { name: 'unplugin-vue-router-auto-hmr', enforce: 'post', @@ -21,9 +24,7 @@ export function createAutoHmrPlugin({ transform(code, id) { if (id.startsWith('\x00')) return - // If you don't use automatically generated routes, - // maybe it will be meaningless to deal with hmr? - if (!code.includes('createRouter(') && !code.includes(modulePath)) { + if (!hasCreateRouterFnCallRegex.test(code)) { return } From 81494cdf70d757a81c137140f26edb04053008fe Mon Sep 17 00:00:00 2001 From: libondev Date: Fri, 21 Nov 2025 14:47:04 +0800 Subject: [PATCH 7/9] feat: autoHmr is enabled by default and supports configuration of filter/modulePath --- src/auto-hmr/index.ts | 52 ++++++++++++++++++++++++++++++++++--------- src/index.ts | 10 ++++----- src/options.ts | 5 +++-- 3 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/auto-hmr/index.ts b/src/auto-hmr/index.ts index 928323154..d791da30d 100644 --- a/src/auto-hmr/index.ts +++ b/src/auto-hmr/index.ts @@ -1,4 +1,3 @@ -import type { UnpluginOptions } from 'unplugin' import type { VariableDeclarator } from 'estree' import { nameFromDeclaration, @@ -6,24 +5,57 @@ import { getHandleHotUpdateDeclaration, hasHandleHotUpdateCall, } from './ast' +import { StringFilter, FilterPattern, type UnpluginOptions } from 'unplugin' +import { createFilter } from 'unplugin-utils' -interface AutoHmrPluginOptions { - modulePath: string +export interface AutoHmrOptions { + /** + * Whether to enable auto HMR for Vue Router. + * @default `true` + */ + enabled?: boolean + + /** + * Filter to determine which files to process. + */ + filter?: Exclude + + /** + * Name of the module to import the handleHotUpdate function from. + * @default `'vue-router/auto-routes'` + */ + modulePath?: string } +export const DEFAULT_AUTO_HMR_OPTIONS = { + enabled: true, + modulePath: 'vue-router/auto-routes', + filter: { + include: ['**/router.{js,ts}', '**/router/index.{js,ts}'], + exclude: [], + }, +} satisfies AutoHmrOptions + export function createAutoHmrPlugin({ - modulePath, -}: AutoHmrPluginOptions): UnpluginOptions { + filter, + modulePath = DEFAULT_AUTO_HMR_OPTIONS.modulePath, +}: AutoHmrOptions): UnpluginOptions { const hasCreateRouterFnCallRegex = /\w+\s*=\s*(?:experimental_)?createRouter\(/ + const shouldProcessId = createFilter( + filter?.include ?? DEFAULT_AUTO_HMR_OPTIONS.filter.include, + filter?.exclude ?? DEFAULT_AUTO_HMR_OPTIONS.filter.exclude + ) + return { name: 'unplugin-vue-router-auto-hmr', enforce: 'post', - transform(code, id) { if (id.startsWith('\x00')) return + if (!shouldProcessId(id)) return + if (!hasCreateRouterFnCallRegex.test(code)) { return } @@ -43,9 +75,9 @@ export function createAutoHmrPlugin({ if (!routerName) { routerDeclaration = getRouterDeclaration( node.type === 'VariableDeclaration' - ? node.declarations - : node.declaration?.type === 'VariableDeclaration' - ? node.declaration?.declarations + ? node.declarations + : node.declaration?.type === 'VariableDeclaration' + ? node.declaration?.declarations : undefined ) @@ -78,7 +110,7 @@ export function createAutoHmrPlugin({ } } - return + return undefined }, } } diff --git a/src/index.ts b/src/index.ts index 4939fc88b..893f4b1da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -169,12 +169,10 @@ export default createUnplugin((opt = {}, _meta) => { ) } - if (options.autoHmr) { - plugins.push( - createAutoHmrPlugin({ - modulePath: MODULE_ROUTES_PATH, - }) - ) + // If the autoHmr configuration item is not configured or only filter is set, + // it will also be regarded as enabled. + if (options.autoHmr?.enabled ?? true) { + plugins.push(createAutoHmrPlugin(options.autoHmr)) } return plugins diff --git a/src/options.ts b/src/options.ts index 9bcc12450..8aba83ee8 100644 --- a/src/options.ts +++ b/src/options.ts @@ -5,6 +5,7 @@ import { resolve } from 'pathe' import { EditableTreeNode } from './core/extendRoutes' import { type ParseSegmentOptions } from './core/treeNodeValue' import { type _Awaitable } from './utils' +import { DEFAULT_AUTO_HMR_OPTIONS, type AutoHmrOptions } from './auto-hmr' /** * Options for a routes folder. @@ -217,9 +218,8 @@ export interface Options { /** * Whether to enable auto HMR for Vue Router. - * @default `false` */ - autoHmr?: boolean + autoHmr?: AutoHmrOptions /** * Experimental options. **Warning**: these can change or be removed at any time, even it patch releases. Keep an eye @@ -272,6 +272,7 @@ export const DEFAULT_OPTIONS = { }, watch: !process.env.CI, experimental: {}, + autoHmr: DEFAULT_AUTO_HMR_OPTIONS, } satisfies Options export interface ServerContext { From c01f6909ecf1e1096838af688c3e78180c64ed75 Mon Sep 17 00:00:00 2001 From: libondev Date: Fri, 21 Nov 2025 14:57:54 +0800 Subject: [PATCH 8/9] test: add auto-hmr test cases --- src/auto-hmr/auto-hmr.spec.ts | 754 ++++++++++++++++++++++++++++++++++ 1 file changed, 754 insertions(+) create mode 100644 src/auto-hmr/auto-hmr.spec.ts diff --git a/src/auto-hmr/auto-hmr.spec.ts b/src/auto-hmr/auto-hmr.spec.ts new file mode 100644 index 000000000..3c6593ca0 --- /dev/null +++ b/src/auto-hmr/auto-hmr.spec.ts @@ -0,0 +1,754 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { createAutoHmrPlugin, DEFAULT_AUTO_HMR_OPTIONS } from './index' + +// Mock the parse function +const mockParse = vi.fn() + +function createMockTransformContext() { + return { + parse: mockParse, + } as any +} + +function callTransform( + plugin: ReturnType, + code: string, + id: string, + ctx: ReturnType +) { + const transform = plugin.transform as any + return transform.call(ctx, code, id) +} + +describe('auto-hmr', () => { + beforeEach(() => { + mockParse.mockClear() + }) + describe('file filtering', () => { + it('should process files matching default filter pattern', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({})` + const id1 = '/path/to/router.ts' + const id2 = '/path/to/router/index.ts' + const id3 = '/path/to/router.js' + const id4 = '/path/to/router/index.js' + + // Should match router.ts + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + const result1 = callTransform(plugin, code, id1, ctx) + expect(result1).toBeDefined() + + const result2 = callTransform(plugin, code, id2, ctx) + expect(result2).toBeDefined() + + const result3 = callTransform(plugin, code, id3, ctx) + expect(result3).toBeDefined() + + const result4 = callTransform(plugin, code, id4, ctx) + expect(result4).toBeDefined() + }) + + it('should not process files not matching filter pattern', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({})` + const id = '/path/to/other.ts' + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeUndefined() + }) + + it('should respect custom filter include pattern', () => { + const plugin = createAutoHmrPlugin({ + filter: { + include: ['**/custom-router.{js,ts}'], + exclude: [], + }, + }) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({})` + const matchingId = '/path/to/custom-router.ts' + const nonMatchingId = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + const result1 = callTransform(plugin, code, matchingId, ctx) + expect(result1).toBeDefined() + + const result2 = callTransform(plugin, code, nonMatchingId, ctx) + expect(result2).toBeUndefined() + }) + + it('should respect custom filter exclude pattern', () => { + const plugin = createAutoHmrPlugin({ + filter: { + include: ['**/router.{js,ts}'], + exclude: ['**/router.test.{js,ts}'], + }, + }) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({})` + const excludedId = '/path/to/router.test.ts' + const includedId = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + const result1 = callTransform(plugin, code, excludedId, ctx) + expect(result1).toBeUndefined() + + const result2 = callTransform(plugin, code, includedId, ctx) + expect(result2).toBeDefined() + }) + + it('should skip virtual modules (starting with \\x00)', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({})` + const virtualId = '\x00virtual-module' + + const result = callTransform(plugin, code, virtualId, ctx) + expect(result).toBeUndefined() + expect(mockParse).not.toHaveBeenCalled() + }) + }) + + describe('createRouter detection', () => { + it('should process files with createRouter call', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + expect(result?.code).toContain('handleHotUpdate') + }) + + it('should process files with experimental_createRouter call', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const router = experimental_createRouter({})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { + type: 'Identifier', + name: 'experimental_createRouter', + }, + }, + }, + ], + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + expect(result?.code).toContain('handleHotUpdate') + }) + + it('should not process files without createRouter call', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + // Use a file that matches the filter but doesn't have createRouter + const code = `const router = somethingElse({})` + const id = '/path/to/router.ts' + + const result = callTransform(plugin, code, id, ctx) + // Should return undefined because regex doesn't match createRouter + expect(result).toBeUndefined() + // Parse should not be called because regex check happens before parse + expect(mockParse).not.toHaveBeenCalled() + }) + }) + + describe('router declaration detection', () => { + it('should detect router from const declaration', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + expect(result?.code).toContain('handleHotUpdate(router)') + }) + + it('should detect router from export const declaration', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `export const router = createRouter({})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + expect(result?.code).toContain('handleHotUpdate(router)') + }) + + it('should handle multiple declarations and find router', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const other = 1; const router = createRouter({})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'other' }, + init: { type: 'Literal', value: 1 }, + }, + ], + }, + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + expect(result?.code).toContain('handleHotUpdate(router)') + }) + + it('should not process if no router declaration found', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const something = createRouter({})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'something' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + // This should still work because the regex matches createRouter + // but we need to check if routerName is found + const result = callTransform(plugin, code, id, ctx) + // The code has createRouter but the variable name is 'something', not 'router' + // So it should still add handleHotUpdate but with 'something' + expect(result).toBeDefined() + }) + }) + + describe('import injection', () => { + it('should add handleHotUpdate import if not present', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + expect(result?.code).toContain( + `import { handleHotUpdate } from '${DEFAULT_AUTO_HMR_OPTIONS.modulePath}'` + ) + expect(result?.code).toContain('handleHotUpdate(router)') + }) + + it('should not add import if already present', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `import { handleHotUpdate } from 'vue-router/auto-routes' +const router = createRouter({})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'ImportDeclaration', + source: { value: 'vue-router/auto-routes' }, + specifiers: [ + { + type: 'ImportSpecifier', + imported: { type: 'Identifier', name: 'handleHotUpdate' }, + }, + ], + }, + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + // Should not duplicate the import + const importCount = (result?.code.match(/import.*handleHotUpdate/g) || []) + .length + expect(importCount).toBe(1) + }) + + it('should use custom modulePath when provided', () => { + const customModulePath = 'custom-module-path' + const plugin = createAutoHmrPlugin({ modulePath: customModulePath }) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + expect(result?.code).toContain( + `import { handleHotUpdate } from '${customModulePath}'` + ) + }) + }) + + describe('handleHotUpdate call injection', () => { + it('should add handleHotUpdate call if not present', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + expect(result?.code).toContain('handleHotUpdate(router)') + }) + + it('should not add call if handleHotUpdate is already called', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({}) +handleHotUpdate(router)` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + { + type: 'ExpressionStatement', + expression: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'handleHotUpdate' }, + }, + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + // Should not duplicate the call + const callCount = (result?.code.match(/handleHotUpdate\(router\)/g) || []) + .length + expect(callCount).toBe(1) + }) + + it('should detect handleHotUpdate call as member expression', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const router = createRouter({}) +autoRouter.handleHotUpdate(router)` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + { + type: 'ExpressionStatement', + expression: { + type: 'CallExpression', + callee: { + type: 'MemberExpression', + object: { type: 'Identifier', name: 'autoRouter' }, + property: { type: 'Identifier', name: 'handleHotUpdate' }, + }, + }, + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + // Should not add another call + const callCount = (result?.code.match(/handleHotUpdate/g) || []).length + // Should have the import and the existing call + expect(callCount).toBeGreaterThanOrEqual(1) + }) + }) + + describe('complete transformation', () => { + it('should transform complete router file correctly', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `import { createRouter, createWebHistory } from 'vue-router' +import { routes } from 'vue-router/auto-routes' + +export const router = createRouter({ + history: createWebHistory(), + routes, +})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'ImportDeclaration', + source: { value: 'vue-router' }, + specifiers: [], + }, + { + type: 'ImportDeclaration', + source: { value: 'vue-router/auto-routes' }, + specifiers: [], + }, + { + type: 'ExportNamedDeclaration', + declaration: { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + expect(result?.code).toContain( + `import { handleHotUpdate } from '${DEFAULT_AUTO_HMR_OPTIONS.modulePath}'` + ) + expect(result?.code).toContain('handleHotUpdate(router)') + expect(result?.code).toContain( + "import { createRouter, createWebHistory } from 'vue-router'" + ) + }) + + it('should preserve original code structure', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const originalCode = `const router = createRouter({}) +const other = 123` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'router' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'other' }, + init: { type: 'Literal', value: 123 }, + }, + ], + }, + ], + }) + + const result = callTransform(plugin, originalCode, id, ctx) + expect(result).toBeDefined() + expect(result?.code).toContain('const router = createRouter({})') + expect(result?.code).toContain('const other = 123') + }) + }) + + describe('edge cases', () => { + it('should handle empty code', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `` + const id = '/path/to/router.ts' + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeUndefined() + }) + + it('should handle code without createRouter', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + // Use a file that matches the filter but doesn't have createRouter + const code = `const something = 123` + const id = '/path/to/router.ts' + + const result = callTransform(plugin, code, id, ctx) + // Should return undefined because regex doesn't match createRouter + expect(result).toBeUndefined() + // Parse should not be called because regex check happens before parse + expect(mockParse).not.toHaveBeenCalled() + }) + + it('should handle different router variable names', () => { + const plugin = createAutoHmrPlugin({}) + const ctx = createMockTransformContext() + + const code = `const myRouter = createRouter({})` + const id = '/path/to/router.ts' + + mockParse.mockReturnValue({ + body: [ + { + type: 'VariableDeclaration', + declarations: [ + { + id: { type: 'Identifier', name: 'myRouter' }, + init: { + type: 'CallExpression', + callee: { type: 'Identifier', name: 'createRouter' }, + }, + }, + ], + }, + ], + }) + + const result = callTransform(plugin, code, id, ctx) + expect(result).toBeDefined() + expect(result?.code).toContain('handleHotUpdate(myRouter)') + }) + }) +}) From aecc136900c831d588d922a5a686d88178cdd447 Mon Sep 17 00:00:00 2001 From: libondev Date: Fri, 21 Nov 2025 15:03:41 +0800 Subject: [PATCH 9/9] perf: avoid circular references to nodes in ast --- src/auto-hmr/ast.ts | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/auto-hmr/ast.ts b/src/auto-hmr/ast.ts index 31680802b..8617fa7d4 100644 --- a/src/auto-hmr/ast.ts +++ b/src/auto-hmr/ast.ts @@ -29,15 +29,18 @@ export function getHandleHotUpdateDeclaration(node?: ImportDeclaration, modulePa } export function hasHandleHotUpdateCall(ast: AstNode) { + const visited = new WeakSet() + function traverse(node: any) { - if (!node) return false; + if (!node || typeof node !== 'object' || visited.has(node)) return false + visited.add(node) if ( node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'handleHotUpdate' ) { - return true; + return true } // e.g.: autoRouter.handleHotUpdate() @@ -47,25 +50,25 @@ export function hasHandleHotUpdateCall(ast: AstNode) { node.callee.property.type === 'Identifier' && node.callee.property.name === 'handleHotUpdate' ) { - return true; + return true } - if (typeof node !== 'object') return false; + if (typeof node !== 'object') return false for (const key in node) { - if (key === 'type' || key === 'loc' || key === 'range') continue; + if (key === 'type' || key === 'loc' || key === 'range') continue - const child = node[key]; + const child = node[key] if (Array.isArray(child)) { for (const item of child) { - if (traverse(item)) return true; + if (traverse(item)) return true } } else if (typeof child === 'object' && child !== null) { - if (traverse(child)) return true; + if (traverse(child)) return true } } - return false; + return false } return traverse(ast);