Skip to content

Commit d999720

Browse files
committed
perf(navigation): skip leaf scans without placeholders
1 parent dd8c61f commit d999720

2 files changed

Lines changed: 96 additions & 1 deletion

File tree

src/runtime/internal/navigation.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,24 @@ export async function generateNavigationTree<T extends PageCollectionItemBase>(q
4646
...(isObject(content?.navigation) ? (content.navigation as Record<string, unknown>) : {}),
4747
})
4848

49+
const checkedChildren = new WeakMap<ContentNavigationItem[], number | false>()
50+
51+
// Existing child arrays only grow by appending; merges create new arrays.
52+
function findPlaceholder(nodes: ContentNavigationItem[], path: string) {
53+
let checked = checkedChildren.get(nodes) ?? 0
54+
if (checked !== false) {
55+
while (checked < nodes.length && nodes[checked]!.page !== false) {
56+
checked++
57+
}
58+
if (checked === nodes.length) {
59+
checkedChildren.set(nodes, checked)
60+
return
61+
}
62+
checkedChildren.set(nodes, false)
63+
}
64+
return nodes.find(item => item.path === path && item.page === false)
65+
}
66+
4967
// Create navigation object
5068
const nav = contents
5169
.reduce((nav, content) => {
@@ -146,7 +164,7 @@ export async function generateNavigationTree<T extends PageCollectionItemBase>(q
146164
}, nav)
147165

148166
// Check for duplicate link
149-
const existed = siblings.find(item => item.path === navItem.path && item.page === false)
167+
const existed = findPlaceholder(siblings, navItem.path)
150168
if (existed) {
151169
Object.assign(existed, {
152170
...navItem,

test/unit/generateNavigationTree.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,83 @@ describe('generateNavigationTree', () => {
1717
all: async () => items,
1818
} as unknown as CollectionQueryBuilder<PageCollectionItemBase>)
1919

20+
const queryInOrder = (items: Partial<PageCollectionItemBase>[]) => ({
21+
__params: { orderBy: ['caller order'] },
22+
orWhere() { return this },
23+
select() { return this },
24+
all: async () => items,
25+
} as unknown as CollectionQueryBuilder<PageCollectionItemBase>)
26+
27+
it.each([false, true])('skips leaf sibling searches with an explicit index: %s', async (withIndex) => {
28+
const pages = Array.from({ length: 100 }, (_, i) => ({
29+
title: `Page ${i}`,
30+
path: `/guide/page-${100 - i}`,
31+
stem: `guide/page-${100 - i}`,
32+
}))
33+
const items = withIndex ? [{ title: 'Guide', path: '/guide', stem: 'guide/index' }, ...pages] : pages
34+
const find = vi.spyOn(Array.prototype, 'find')
35+
try {
36+
const tree = await generateNavigationTree(queryInOrder(items))
37+
const searches = find.mock.calls.length
38+
expect(searches).toBe(pages.length + Number(withIndex))
39+
expect(tree[0]?.children?.map(item => item.path)).toEqual(items.map(item => item.path))
40+
}
41+
finally {
42+
find.mockRestore()
43+
}
44+
})
45+
46+
it.each([false, true])('merges a placeholder appended after a leaf, from metadata: %s', async (fromMetadata) => {
47+
const tree = await generateNavigationTree(queryInOrder([
48+
{ title: 'First', path: '/guide/first', stem: 'guide/first' },
49+
fromMetadata
50+
? { title: 'Placeholder', path: '/guide/topic', stem: 'guide/topic', navigation: { page: false } }
51+
: { title: 'Child', path: '/guide/topic/child', stem: 'guide/topic/child' },
52+
{ title: 'Topic', path: '/guide/topic', stem: 'guide/topic' },
53+
]))
54+
55+
expect(tree[0]?.children?.map(item => item.path)).toEqual(['/guide/first', '/guide/topic'])
56+
expect(tree[0]?.children?.[1]).toMatchObject({ title: 'Topic', page: undefined })
57+
expect(tree[0]?.children?.[1]?.children?.map(item => item.path)).toEqual(fromMetadata ? undefined : ['/guide/topic/child'])
58+
})
59+
60+
it.each([false, true])('merges supplied duplicate placeholders in order, from directory config: %s', async (fromConfig) => {
61+
const children = ['First', 'Second'].map(title => ({
62+
title,
63+
path: '/guide/topic',
64+
page: false,
65+
children: [{ title, path: `/guide/topic/${title.toLowerCase()}` }],
66+
}))
67+
const roots = fromConfig
68+
? [
69+
{ title: 'Config', path: '/guide/.navigation', stem: 'guide/.navigation', meta: { children } },
70+
{ title: 'Guide', path: '/guide', stem: 'guide/index' },
71+
]
72+
: [{ title: 'Guide', path: '/guide', stem: 'guide', navigation: { children } }]
73+
const tree = await generateNavigationTree(queryInOrder([
74+
...roots,
75+
{ title: 'Replacement 1', path: '/guide/topic', stem: 'guide/topic' },
76+
{ title: 'Replacement 2', path: '/guide/topic', stem: 'guide/topic' },
77+
]))
78+
79+
expect(tree[0]?.children?.map(item => item.title)).toEqual(['Replacement 1', 'Replacement 2'])
80+
expect(tree[0]?.children?.map(item => item.page)).toEqual([undefined, undefined])
81+
expect(tree[0]?.children?.map(item => item.children?.[0]?.path)).toEqual(['/guide/topic/first', '/guide/topic/second'])
82+
})
83+
84+
it.each([false, true])('preserves index merging and later siblings, child first: %s', async (childFirst) => {
85+
const index = { title: 'Topic', path: '/guide/topic', stem: 'guide/topic/index' }
86+
const child = { title: 'First', path: '/guide/topic/first', stem: 'guide/topic/first' }
87+
const tree = await generateNavigationTree(queryInOrder([
88+
...(childFirst ? [child, index] : [index, child]),
89+
{ title: 'Second', path: '/guide/topic/second', stem: 'guide/topic/second' },
90+
]))
91+
92+
expect(tree[0]?.children?.[0]?.children?.map(item => item.path)).toEqual([
93+
'/guide/topic', '/guide/topic/first', '/guide/topic/second',
94+
])
95+
})
96+
2097
it('should generate a basic navigation tree', async () => {
2198
const items = [
2299
{

0 commit comments

Comments
 (0)