]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
fix(unplugin): Avoid generating empty routes (#2642) main
author一只前端汪 <985313519@qq.com>
Fri, 24 Apr 2026 09:54:22 +0000 (17:54 +0800)
committerGitHub <noreply@github.com>
Fri, 24 Apr 2026 09:54:22 +0000 (11:54 +0200)
Co-authored-by: Eduardo San Martin Morote <posva13@gmail.com>
packages/router/src/unplugin/codegen/__snapshots__/generateRouteRecords.spec.ts.snap
packages/router/src/unplugin/codegen/generateRouteRecords.spec.ts
packages/router/src/unplugin/core/extendRoutes.spec.ts
packages/router/src/unplugin/core/tree.spec.ts
packages/router/src/unplugin/core/tree.ts

index 202b230b16348732f2a05a5908034c6bf84015c8..6a46989f0b6801dc7e9ea59abf913d4e49cee782 100644 (file)
@@ -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`] = `
 "[
   {
index 1b012035941b544ec5c2c9ed7e9b3827fd8be3da..b587362a21428b1d512b5f2cf7d906ca441297d7 100644 (file)
@@ -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')
index a684bf1fa09b32ad778e141ae3f7671de7c40dca..eac54ed2771d59bf1e68b4be7e61566f5a6104e6 100644 (file)
@@ -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)
index 78295dfc3f7340301623dbe8d7dfba4cf47ff3eb..5db8e9a1d67df49d761eb63be98682b05a704f70 100644 (file)
@@ -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')
index c802eb876efd62cd68f2993cec00146eb77b2e99..6d7942f4b816e12e2ff82f664421d0c56267ab46 100644 (file)
@@ -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
   }