From 3d28a9e79828bbbc904ec5e8bd68ef5b65058f09 Mon Sep 17 00:00:00 2001 From: Chesars Date: Thu, 11 Dec 2025 11:48:52 -0300 Subject: [PATCH 1/4] fix(docs): prioritize category href over links in sidebar breadcrumb search When a sidebar link in one category points to a generated-index page URL from another category, the breadcrumb search would incorrectly return the link's parent category instead of the category that owns the URL. This fix uses a two-pass approach when searching for the current sidebar category: 1. First pass: Look for categories that directly own the URL (category.href) 2. Second pass: If not found, fall back to finding via links (for doc pages) This ensures generated-index pages display their correct category's items even when another category has a link pointing to the same URL. Closes #11612 --- .../src/client/__tests__/docsUtils.test.tsx | 37 +++++++++++++++ .../src/client/docsUtils.tsx | 45 +++++++++++++++++-- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx b/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx index 00263ad5907d..11b836b758ea 100644 --- a/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx +++ b/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx @@ -780,6 +780,43 @@ describe('useCurrentSidebarCategory', () => { `"Unexpected: cant find current sidebar in context"`, ); }); + + // Regression test for https://github.com/facebook/docusaurus/issues/11612 + // When a link in Category A points to a generated-index URL owned by + // Category B, useCurrentSidebarCategory should return Category B (the owner), + // not Category A (which merely has a link pointing to it). + it('returns the category that owns the URL, not a category with a link pointing to it', () => { + // Category B is the actual owner of /category-b (its generated-index href) + const categoryB: PropSidebarItemCategory = testCategory({ + label: 'Category B', + href: '/category-b', + items: [ + testLink({href: '/category-b/item1', label: 'Item 1'}), + testLink({href: '/category-b/item2', label: 'Item 2'}), + ], + }); + + // Category A contains a link that points to Category B's URL + const categoryA: PropSidebarItemCategory = testCategory({ + label: 'Category A', + href: '/category-a', + items: [ + testLink({href: '/category-a/doc1', label: 'Doc 1'}), + testLink({href: '/category-a/doc2', label: 'Doc 2'}), + // This link points to Category B's generated-index + testLink({href: '/category-b', label: 'Go to Category B'}), + ], + }); + + const sidebar: PropSidebar = [categoryA, categoryB]; + + const mockUseCurrentSidebarCategory = + createUseCurrentSidebarCategoryMock(sidebar); + + // When visiting /category-b, we should get Category B (the owner), + // not Category A (which just has a link to it) + expect(mockUseCurrentSidebarCategory('/category-b')).toEqual(categoryB); + }); }); describe('useCurrentSidebarSiblings', () => { diff --git a/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx b/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx index aa00df851034..1ff2f339eefe 100644 --- a/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx +++ b/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx @@ -234,11 +234,40 @@ function getSidebarBreadcrumbs({ }): PropSidebarBreadcrumbsItem[] { const breadcrumbs: PropSidebarBreadcrumbsItem[] = []; - function extract(items: PropSidebarItem[]) { + // When onlyCategories is true (e.g., for useCurrentSidebarCategory), we need + // to distinguish between: + // 1. A category that directly owns this URL (category.href === pathname) + // 2. A link that happens to point to this URL + // + // We should prefer (1) over (2) to fix the bug where a generated-index page + // shows items from the wrong category when another category has a link + // pointing to it. See https://github.com/facebook/docusaurus/issues/11612 + // + // We use a two-pass approach: + // - First pass: Only look for categories that directly own the URL + // - Second pass: If not found, look for links (to support doc pages) + + function extractCategoryOnly(items: PropSidebarItem[]): boolean { + for (const item of items) { + if (item.type === 'category') { + if (isSamePath(item.href, pathname)) { + breadcrumbs.unshift(item); + return true; + } + if (extractCategoryOnly(item.items)) { + breadcrumbs.unshift(item); + return true; + } + } + } + return false; + } + + function extractWithLinks(items: PropSidebarItem[]): boolean { for (const item of items) { if ( (item.type === 'category' && - (isSamePath(item.href, pathname) || extract(item.items))) || + (isSamePath(item.href, pathname) || extractWithLinks(item.items))) || (item.type === 'link' && isSamePath(item.href, pathname)) ) { const filtered = onlyCategories && item.type !== 'category'; @@ -248,11 +277,19 @@ function getSidebarBreadcrumbs({ return true; } } - return false; } - extract(sidebarItems); + if (onlyCategories) { + // First try to find a category that directly owns this URL + if (!extractCategoryOnly(sidebarItems)) { + // Fall back to finding via links (for doc pages in a category) + extractWithLinks(sidebarItems); + } + } else { + // For breadcrumbs, use the original behavior (links included) + extractWithLinks(sidebarItems); + } return breadcrumbs; } From 5492cabab9f97a37c0df290b514811196a064067 Mon Sep 17 00:00:00 2001 From: sebastien Date: Mon, 22 Dec 2025 15:58:38 +0100 Subject: [PATCH 2/4] simplify and remove useless comments --- .../src/client/__tests__/docsUtils.test.tsx | 52 ++++++++++++++----- .../src/client/docsUtils.tsx | 36 ++++--------- 2 files changed, 47 insertions(+), 41 deletions(-) diff --git a/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx b/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx index 11b836b758ea..ce381c5d0ade 100644 --- a/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx +++ b/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx @@ -657,6 +657,35 @@ describe('useSidebarBreadcrumbs', () => { createUseSidebarBreadcrumbsMock(undefined, false)('/foo'), ).toBeNull(); }); + + // Regression test for https://github.com/facebook/docusaurus/issues/11612 + it('returns the category that owns the URL, not a category with a link pointing to it', () => { + const categoryA: PropSidebarItemCategory = testCategory({ + label: 'Category A', + href: '/category-a', + items: [ + testLink({href: '/category-a/doc1', label: 'Doc 1'}), + testLink({href: '/category-a/doc2', label: 'Doc 2'}), + // This link points to Category B's generated-index + testLink({href: '/category-b', label: 'Go to Category B'}), + ], + }); + + const categoryB: PropSidebarItemCategory = testCategory({ + label: 'Category B', + href: '/category-b', + items: [ + testLink({href: '/category-b/item1', label: 'Item 1'}), + testLink({href: '/category-b/item2', label: 'Item 2'}), + ], + }); + + const sidebar: PropSidebar = [categoryA, categoryB]; + + expect(createUseSidebarBreadcrumbsMock(sidebar)('/category-b')).toEqual([ + categoryB, + ]); + }); }); describe('useCurrentSidebarCategory', () => { @@ -782,21 +811,7 @@ describe('useCurrentSidebarCategory', () => { }); // Regression test for https://github.com/facebook/docusaurus/issues/11612 - // When a link in Category A points to a generated-index URL owned by - // Category B, useCurrentSidebarCategory should return Category B (the owner), - // not Category A (which merely has a link pointing to it). it('returns the category that owns the URL, not a category with a link pointing to it', () => { - // Category B is the actual owner of /category-b (its generated-index href) - const categoryB: PropSidebarItemCategory = testCategory({ - label: 'Category B', - href: '/category-b', - items: [ - testLink({href: '/category-b/item1', label: 'Item 1'}), - testLink({href: '/category-b/item2', label: 'Item 2'}), - ], - }); - - // Category A contains a link that points to Category B's URL const categoryA: PropSidebarItemCategory = testCategory({ label: 'Category A', href: '/category-a', @@ -808,6 +823,15 @@ describe('useCurrentSidebarCategory', () => { ], }); + const categoryB: PropSidebarItemCategory = testCategory({ + label: 'Category B', + href: '/category-b', + items: [ + testLink({href: '/category-b/item1', label: 'Item 1'}), + testLink({href: '/category-b/item2', label: 'Item 2'}), + ], + }); + const sidebar: PropSidebar = [categoryA, categoryB]; const mockUseCurrentSidebarCategory = diff --git a/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx b/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx index 1ff2f339eefe..725eaee60f6a 100644 --- a/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx +++ b/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx @@ -234,27 +234,14 @@ function getSidebarBreadcrumbs({ }): PropSidebarBreadcrumbsItem[] { const breadcrumbs: PropSidebarBreadcrumbsItem[] = []; - // When onlyCategories is true (e.g., for useCurrentSidebarCategory), we need - // to distinguish between: - // 1. A category that directly owns this URL (category.href === pathname) - // 2. A link that happens to point to this URL - // - // We should prefer (1) over (2) to fix the bug where a generated-index page - // shows items from the wrong category when another category has a link - // pointing to it. See https://github.com/facebook/docusaurus/issues/11612 - // - // We use a two-pass approach: - // - First pass: Only look for categories that directly own the URL - // - Second pass: If not found, look for links (to support doc pages) - - function extractCategoryOnly(items: PropSidebarItem[]): boolean { + function extractCategory(items: PropSidebarItem[]): boolean { for (const item of items) { if (item.type === 'category') { if (isSamePath(item.href, pathname)) { breadcrumbs.unshift(item); return true; } - if (extractCategoryOnly(item.items)) { + if (extractCategory(item.items)) { breadcrumbs.unshift(item); return true; } @@ -263,11 +250,11 @@ function getSidebarBreadcrumbs({ return false; } - function extractWithLinks(items: PropSidebarItem[]): boolean { + function extract(items: PropSidebarItem[]): boolean { for (const item of items) { if ( (item.type === 'category' && - (isSamePath(item.href, pathname) || extractWithLinks(item.items))) || + (isSamePath(item.href, pathname) || extract(item.items))) || (item.type === 'link' && isSamePath(item.href, pathname)) ) { const filtered = onlyCategories && item.type !== 'category'; @@ -280,16 +267,11 @@ function getSidebarBreadcrumbs({ return false; } - if (onlyCategories) { - // First try to find a category that directly owns this URL - if (!extractCategoryOnly(sidebarItems)) { - // Fall back to finding via links (for doc pages in a category) - extractWithLinks(sidebarItems); - } - } else { - // For breadcrumbs, use the original behavior (links included) - extractWithLinks(sidebarItems); - } + // We use a two-pass approach: + // - First pass: Only look for categories that directly own the URL + // - Second pass: If not found, look for links (to support doc pages) + // See why here: https://github.com/facebook/docusaurus/issues/11612 + extractCategory(sidebarItems) || extract(sidebarItems); return breadcrumbs; } From b2c09c99c55b21888a4e75eda9fbc9c33d953462 Mon Sep 17 00:00:00 2001 From: sebastien Date: Mon, 22 Dec 2025 16:05:40 +0100 Subject: [PATCH 3/4] reduce comment --- .../docusaurus-plugin-content-docs/src/client/docsUtils.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx b/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx index 725eaee60f6a..06d551243aa9 100644 --- a/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx +++ b/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx @@ -267,9 +267,7 @@ function getSidebarBreadcrumbs({ return false; } - // We use a two-pass approach: - // - First pass: Only look for categories that directly own the URL - // - Second pass: If not found, look for links (to support doc pages) + // We use a two-pass approach // See why here: https://github.com/facebook/docusaurus/issues/11612 extractCategory(sidebarItems) || extract(sidebarItems); From 76ae622655078169cac7606ddbaad9250826407b Mon Sep 17 00:00:00 2001 From: sebastien Date: Mon, 22 Dec 2025 16:48:11 +0100 Subject: [PATCH 4/4] getSidebarBreadcrumbs should only return doc links, not link items that do not create any page --- .../src/client/__tests__/docsUtils.test.tsx | 87 +++++++++++++------ .../src/client/docsUtils.tsx | 32 +++---- 2 files changed, 70 insertions(+), 49 deletions(-) diff --git a/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx b/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx index ce381c5d0ade..5d8b30a0cc74 100644 --- a/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx +++ b/packages/docusaurus-plugin-content-docs/src/client/__tests__/docsUtils.test.tsx @@ -568,13 +568,28 @@ describe('useSidebarBreadcrumbs', () => { it('returns first level link', () => { const pathname = '/somePathName'; - const sidebar = [testCategory(), testLink({href: pathname})]; + const sidebar = [testCategory(), testLink({href: pathname, docId: 'doc1'})]; expect(createUseSidebarBreadcrumbsMock(sidebar)(pathname)).toEqual([ sidebar[1], ]); }); + it('returns doc links only', () => { + const pathname = '/somePathName'; + + // A link that is not a doc link should not appear in the breadcrumbs + // See https://github.com/facebook/docusaurus/pull/11616 + const nonDocLink = testLink({href: pathname}); + const docLink = testLink({href: pathname, docId: 'doc1'}); + + const sidebar = [testCategory(), nonDocLink, docLink]; + + expect(createUseSidebarBreadcrumbsMock(sidebar)(pathname)).toEqual([ + docLink, + ]); + }); + it('returns nested category', () => { const pathname = '/somePathName'; @@ -613,7 +628,7 @@ describe('useSidebarBreadcrumbs', () => { it('returns nested link', () => { const pathname = '/somePathName'; - const link = testLink({href: pathname}); + const link = testLink({href: pathname, docId: 'docNested'}); const categoryLevel3 = testCategory({ items: [testLink(), link, testLink()], @@ -737,12 +752,16 @@ describe('useCurrentSidebarCategory', () => { expect(mockUseCurrentSidebarCategory('/cat2')).toEqual(category2); }); - it('works for category link item', () => { - const link = testLink({href: '/my/link/path'}); + it('works for category doc link item', () => { + const pathname = '/my/link/path'; + const nonDocLink = testLink({href: pathname}); + const docLink = testLink({href: pathname, docId: 'doc1'}); + const category: PropSidebarItemCategory = testCategory({ href: '/cat1', - items: [testLink(), testLink(), link, testCategory()], + items: [testLink(), testLink(), nonDocLink, docLink, testCategory()], }); + const sidebar: PropSidebar = [ testLink(), testLink(), @@ -753,18 +772,28 @@ describe('useCurrentSidebarCategory', () => { const mockUseCurrentSidebarCategory = createUseCurrentSidebarCategoryMock(sidebar); - expect(mockUseCurrentSidebarCategory('/my/link/path')).toEqual(category); + expect(mockUseCurrentSidebarCategory(pathname)).toEqual(category); }); it('works for nested category link item', () => { - const link = testLink({href: '/my/link/path'}); + const pathname = '/my/link/path'; + const nonDocLink = testLink({href: pathname}); + const docLink = testLink({href: pathname, docId: 'doc1'}); + const category2: PropSidebarItemCategory = testCategory({ href: '/cat2', - items: [testLink(), testLink(), link, testCategory()], + items: [ + testLink(), + testLink(), + testCategory({items: [nonDocLink]}), + nonDocLink, + docLink, + testCategory(), + ], }); const category1: PropSidebarItemCategory = testCategory({ href: '/cat1', - items: [testLink(), testLink(), category2, testCategory()], + items: [testLink(), nonDocLink, testLink(), category2, testCategory()], }); const sidebar: PropSidebar = [ testLink(), @@ -866,10 +895,10 @@ describe('useCurrentSidebarSiblings', () => { testCategory(), ]; - const mockUseCurrentSidebarCategory = + const mockUseCurrentSidebarSiblings = createUseCurrentSidebarSiblingsMock(sidebar); - expect(mockUseCurrentSidebarCategory('/cat')).toEqual(category.items); + expect(mockUseCurrentSidebarSiblings('/cat')).toEqual(category.items); }); it('works for sidebar root', () => { @@ -884,10 +913,10 @@ describe('useCurrentSidebarSiblings', () => { testCategory(), ]; - const mockUseCurrentSidebarCategory = + const mockUseCurrentSidebarSiblings = createUseCurrentSidebarSiblingsMock(sidebar); - expect(mockUseCurrentSidebarCategory('/rootLink')).toEqual(sidebar); + expect(mockUseCurrentSidebarSiblings('/rootLink')).toEqual(sidebar); }); it('works for nested sidebar category', () => { @@ -913,10 +942,13 @@ describe('useCurrentSidebarSiblings', () => { }); it('works for category link item', () => { - const link = testLink({href: '/my/link/path'}); + const pathname = '/my/link/path'; + const nonDocLink = testLink({href: pathname}); + const docLink = testLink({href: pathname, docId: 'doc1'}); + const category: PropSidebarItemCategory = testCategory({ href: '/cat1', - items: [testLink(), testLink(), link, testCategory()], + items: [testLink(), testLink(), nonDocLink, docLink, testCategory()], }); const sidebar: PropSidebar = [ testLink(), @@ -925,23 +957,24 @@ describe('useCurrentSidebarSiblings', () => { testCategory(), ]; - const mockUseCurrentSidebarCategory = + const mockUseCurrentSidebarSiblings = createUseCurrentSidebarSiblingsMock(sidebar); - expect(mockUseCurrentSidebarCategory('/my/link/path')).toEqual( - category.items, - ); + expect(mockUseCurrentSidebarSiblings(pathname)).toEqual(category.items); }); it('works for nested category link item', () => { - const link = testLink({href: '/my/link/path'}); + const pathname = '/my/link/path'; + const nonDocLink = testLink({href: pathname}); + const docLink = testLink({href: pathname, docId: 'doc1'}); + const category2: PropSidebarItemCategory = testCategory({ href: '/cat2', - items: [testLink(), testLink(), link, testCategory()], + items: [testLink(), testLink(), nonDocLink, testCategory()], }); const category1: PropSidebarItemCategory = testCategory({ href: '/cat1', - items: [testLink(), testLink(), category2, testCategory()], + items: [testLink(), testLink(), category2, docLink, testCategory()], }); const sidebar: PropSidebar = [ testLink(), @@ -950,18 +983,16 @@ describe('useCurrentSidebarSiblings', () => { testCategory(), ]; - const mockUseCurrentSidebarCategory = + const mockUseCurrentSidebarSiblings = createUseCurrentSidebarSiblingsMock(sidebar); - expect(mockUseCurrentSidebarCategory('/my/link/path')).toEqual( - category2.items, - ); + expect(mockUseCurrentSidebarSiblings(pathname)).toEqual(category1.items); }); it('throws when sidebar is missing', () => { - const mockUseCurrentSidebarCategory = createUseCurrentSidebarSiblingsMock(); + const mockUseCurrentSidebarSiblings = createUseCurrentSidebarSiblingsMock(); expect(() => - mockUseCurrentSidebarCategory('/cat'), + mockUseCurrentSidebarSiblings('/cat'), ).toThrowErrorMatchingInlineSnapshot( `"Unexpected: cant find current sidebar in context"`, ); diff --git a/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx b/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx index 06d551243aa9..474a58895ade 100644 --- a/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx +++ b/packages/docusaurus-plugin-content-docs/src/client/docsUtils.tsx @@ -234,42 +234,32 @@ function getSidebarBreadcrumbs({ }): PropSidebarBreadcrumbsItem[] { const breadcrumbs: PropSidebarBreadcrumbsItem[] = []; - function extractCategory(items: PropSidebarItem[]): boolean { + function extract(items: PropSidebarItem[]): boolean { for (const item of items) { + // Extract category item if (item.type === 'category') { - if (isSamePath(item.href, pathname)) { - breadcrumbs.unshift(item); - return true; - } - if (extractCategory(item.items)) { + if (isSamePath(item.href, pathname) || extract(item.items)) { breadcrumbs.unshift(item); return true; } } - } - return false; - } - - function extract(items: PropSidebarItem[]): boolean { - for (const item of items) { - if ( - (item.type === 'category' && - (isSamePath(item.href, pathname) || extract(item.items))) || - (item.type === 'link' && isSamePath(item.href, pathname)) + // Extract doc item + else if ( + item.type === 'link' && + item.docId && + isSamePath(item.href, pathname) ) { - const filtered = onlyCategories && item.type !== 'category'; - if (!filtered) { + if (!onlyCategories) { breadcrumbs.unshift(item); } return true; } } + return false; } - // We use a two-pass approach - // See why here: https://github.com/facebook/docusaurus/issues/11612 - extractCategory(sidebarItems) || extract(sidebarItems); + extract(sidebarItems); return breadcrumbs; }