From: 一只前端汪 <985313519@qq.com> Date: Fri, 24 Apr 2026 09:54:22 +0000 (+0800) Subject: fix(unplugin): Avoid generating empty routes (#2642) X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=HEAD;p=thirdparty%2Fvuejs%2Frouter.git fix(unplugin): Avoid generating empty routes (#2642) Co-authored-by: Eduardo San Martin Morote --- diff --git a/packages/router/src/unplugin/codegen/__snapshots__/generateRouteRecords.spec.ts.snap b/packages/router/src/unplugin/codegen/__snapshots__/generateRouteRecords.spec.ts.snap index 202b230b1..6a46989f0 100644 --- a/packages/router/src/unplugin/codegen/__snapshots__/generateRouteRecords.spec.ts.snap +++ b/packages/router/src/unplugin/codegen/__snapshots__/generateRouteRecords.spec.ts.snap @@ -104,6 +104,24 @@ exports[`generateRouteRecord > does not encode RFC 3986 valid path characters 1` ]" `; +exports[`generateRouteRecord > drops the wrapper when its last child is deleted 1`] = ` +"[ + { + path: '/home', + /* internal name: '/home' */ + /* no component */ + children: [ + { + path: 'user', + name: '/home/user', + component: () => import('home/user.vue'), + /* no children */ + } + ], + } +]" +`; + exports[`generateRouteRecord > encodes special characters in path segments 1`] = ` "[ { diff --git a/packages/router/src/unplugin/codegen/generateRouteRecords.spec.ts b/packages/router/src/unplugin/codegen/generateRouteRecords.spec.ts index 1b0120359..b587362a2 100644 --- a/packages/router/src/unplugin/codegen/generateRouteRecords.spec.ts +++ b/packages/router/src/unplugin/codegen/generateRouteRecords.spec.ts @@ -49,6 +49,29 @@ describe('generateRouteRecord', () => { expect(generateRouteRecordSimple(tree)).toContain("path: '/nested'") }) + it('skips nested lone _parent files', () => { + const tree = new PrefixTree(DEFAULT_OPTIONS) + tree.insert('users/index', 'users/index.vue') + tree.insert('users/settings/_parent', 'users/settings/_parent.vue') + + const routes = generateRouteRecordSimple(tree) + + expect(routes).toContain("path: '/users'") + expect(routes).not.toContain("path: '/users/settings'") + }) + + // regression test for https://github.com/vuejs/router/issues/2641 + // when beforeWriteFiles removes the only child of a wrapper node, the + // wrapper must disappear too instead of emitting an empty route record + it('drops the wrapper when its last child is deleted', () => { + const tree = new PrefixTree(DEFAULT_OPTIONS) + tree.insert('home/user', 'home/user.vue') + const leaf = tree.insert('home/component/foo', 'home/component/foo.vue') + leaf.delete() + + expect(generateRouteRecordSimple(tree)).toMatchSnapshot() + }) + it('works with some paths at root', () => { const tree = new PrefixTree(DEFAULT_OPTIONS) tree.insert('a', 'a.vue') diff --git a/packages/router/src/unplugin/core/extendRoutes.spec.ts b/packages/router/src/unplugin/core/extendRoutes.spec.ts index a684bf1fa..eac54ed27 100644 --- a/packages/router/src/unplugin/core/extendRoutes.spec.ts +++ b/packages/router/src/unplugin/core/extendRoutes.spec.ts @@ -35,6 +35,22 @@ describe('EditableTreeNode', () => { expect(tree.children.get('foo')?.path).toBe('/foo') }) + it('removes parent when deleting last child of a non-matchable node', () => { + const tree = new PrefixTree(RESOLVED_OPTIONS) + + const editable = new EditableTreeNode(tree) + editable.insert('about', 'about.vue') + const toDelete = editable.insert('a/b/c', 'a/b/c.vue') + + expect(tree.children.size).toBe(2) + toDelete.delete() + expect(tree.children.size).toBe(1) + expect(tree.children.has('a')).toBe(false) + expect(tree.children.has('about')).toBe(true) + // repeatedly deleting a node should not throw + expect(() => toDelete.delete()).not.toThrow() + }) + it('keeps nested routes flat', () => { const tree = new PrefixTree(RESOLVED_OPTIONS) const editable = new EditableTreeNode(tree) diff --git a/packages/router/src/unplugin/core/tree.spec.ts b/packages/router/src/unplugin/core/tree.spec.ts index 78295dfc3..5db8e9a1d 100644 --- a/packages/router/src/unplugin/core/tree.spec.ts +++ b/packages/router/src/unplugin/core/tree.spec.ts @@ -509,6 +509,14 @@ describe('Tree', () => { expect(tree.children.size).toBe(1) }) + it('removes parent when deleting last child of a non-matchable node', () => { + const tree = new PrefixTree(RESOLVED_OPTIONS) + const abc = tree.insert('a/b/c', 'a/b/c.vue') + expect(tree.children.has('a')).toBe(true) + abc.delete() + expect(tree.children.has('a')).toBe(false) + }) + it('handles multiple params', () => { const tree = new PrefixTree(RESOLVED_OPTIONS) tree.insert('[a]-[b]', '[a]-[b].vue') diff --git a/packages/router/src/unplugin/core/tree.ts b/packages/router/src/unplugin/core/tree.ts index c802eb876..6d7942f4b 100644 --- a/packages/router/src/unplugin/core/tree.ts +++ b/packages/router/src/unplugin/core/tree.ts @@ -228,15 +228,30 @@ export class TreeNode { return Array.from(this.getChildrenDeep()).sort(TreeNode.compare) } + /** + * Delete a child node. If the child node has no more children and no + * components, it will be deleted as well. This is used to recursively delete + * empty nodes after removing a route. + * + * @param child - child node to delete + */ + protected deleteChild(child: TreeNode): void { + this.children.delete(child.value.rawSegment) + // recursively delete empty parents + if (!this.isRoot() && !this.isMatchable() && this.children.size === 0) { + this.delete() + } + } + /** * Delete and detach itself from the tree. */ - delete() { - if (!this.parent) { + delete(): void { + if (this.isRoot()) { throw new Error('Cannot delete the root node.') } - this.parent.children.delete(this.value.rawSegment) - // clear link to parent + this.parent?.deleteChild(this) + // clear link to parent so a repeated delete() is a no-op this.parent = undefined }