Repository navigation
perf: avoid repeated sidebar subtree scans - #4149
Conversation
🦋 Changeset detectedLatest commit: cfdba20 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
✅ Deploy Preview for astro-starlight ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
size-limit report 📦
|
There was a problem hiding this comment.
I wonder if instead of optimising this here we should add some logic in navigation.ts to tag groups as current when building the sidebar? We’re already marking current leaf nodes in the tree there, but I guess we could also mark the ancestors of the current item at the same time?
This would have a couple of advantages:
- It could be faster because we could do it inside some existing iterations.
- It means the logic is abstracted away from the rendering component so users who reimplement the component don’t also need to reimplement this.
There is a small risk though: if a user for some reason adds/changes the current page link in route middleware, they would be responsible for marking ancestors at the same time because the component would not do it for them any more. Similarly, if the current page link was removed, they’d need to remove the current marker from the containing folders too. I’m not sure how common those scenarios are to evaluate the risk–benefit trade-off. Might need some more feedback to decide for sure.
(A third option would be to have a new layer of Starlight logic that runs after user middleware but before rendering to do work like this, but I’m not sure that’s a good idea because the middleware is kind of the escape hatch for changing Starlight behaviour, so to enforce stuff on top of it is potentially undesirable.)
1e2b874 to
e91796c
Compare
|
There's the risk that if a user uses the route middleware to change the sidebar, the information will become stale and could cause some undesired behaviour |
Merging this PR will improve performance by 41.49%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | sidebar current groups |
13.2 ms | 9.3 ms | +41.49% |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing perf/sidebar-current-groups (cfdba20) with main (fafea30)
delucis
left a comment
There was a problem hiding this comment.
OK, thinking about this some more, I think the code does need to run at this level — it’s essentially a rendering concern not a data one. So running it in the component makes sense.
I do think it would be nice to keep the behaviour a bit more reusable if someone is overriding the component. I considered a utility attached to Astro.locals.starlightRoute, which could be lazily initialised for users to simplify the API and not require prop drilling:
<details open={Astro.locals.sidebarEntryHasCurrent(entry) || !entry.collapsed}>But I’m not 100% sure about introducing functions on the route data object as it’s not something we’ve done until now and there may be gotchas. It would be the cleanest for users though.
|
While thinking about Chris's comments, I looked at other places where we recursively walk the sidebar on every page. These should definitely be measured for performance improvements as well, but maybe these other places could also benefit from similar optimizations.
Now to get back to Chris's comments:
While true, and if I'm not missing anything, I don't see any change in the new implementation that would require us to do so at the same time? Nothing would change for people overriding the sidebar and re-using the publicly exported I guess the idea would be to make it easier to write custom
Adding a function to the route data could be a bit scary. One potential issue coming to mind is that anything that serialises or copies the route data could break.
If I understand correctly, I guess this could be tricky to get right, e.g. what if a middleware calls the helper and then mutates the sidebar? If we were to rely on a helper using The flag on groups, computed after route middleware, I guess could work, but it's definitely a new kind of route-data field, readable in components but not during middleware. Tricky to document and reason about I would guess. I probably need to think more about it but without too much thinking, exported helpers sound way less scary at first glance (but still a new "concept" in Starlight (but less difficult to reason about compared to the flag on groups)). It could be a |
|
Thanks for the feedback and thoughts @HiDeoo! OK, in that case probably an exported helper — like
Correct. I was mostly thinking how we get the complaint sometimes from people reimplementing components in overrides that they can’t access utilities imported in the original implementation (i.e. they want to be able to copy/paste our implementation, then modify). My general approach has been to try to hoist as much data processing up into route data as possible, so that our components can be focused on presentation and are a bit easier to reuse or reference. But this is definitely hard in some cases like this one where you want to support data mutation and the current group is a derived state. So in theory, standardising and exporting utilities like this one day could be a solution. But not one we have to take today.
Yeah, the utility would only be really designed for use in a component/after mutations are done, so tricky for sure. The other — more disruptive — change could be to hide the sidebar data structure behind custom APIs that take care of mutating the tree instead of interacting directly with the underlying arrays of entries. That way current state could be flagged initially and then managed by those APIs. But moving away from plain JS data structures and the flexibility that gives is not the easiest thing to design for without limiting what people can do. Essentially you would want these behaviours I guess:
For the set/remove cases that’s just walking up the chain of parents, which could be quite cheap to do. For the sub-tree insertion case, you need to dive into the new sub-tree to find if there is a current child before walking up. But in any case, that’s probably not justified for now. Maybe if in some future we have more complex sidebar data structures to handle multiple sidebars etc. we could find ourselves wanting to help users with such an API and could do this then. |
I believe this is a good comprise. It provides enough customisation, while being protective enough of the internal Starlight infra, so that it's possible to change the structure as we see fit. FYI Nimbus added something similar https://nimbus-docs.com/navigation/sidebar/#transform-the-final-tree |
|
As suggested by @HiDeoo I switched the implementation to use a So now the component can just call Now this is in a separate utility I think we could probably benchmark it more easily so will take a look after lunch at writing a before/after comparison. |
|
~3x speed-up: Tested this locally in our existing functional benchmark file with a small function to mock the recursive rendering. Benchmark codefunction mockSidebarRender(sublist: SidebarEntry[], getOpen: (entry: SidebarGroup) => boolean) {
sublist.map((entry) => {
if (entry.type !== 'link') {
getOpen(entry);
mockSidebarRender(entry.entries, getOpen);
}
});
}
describe('sidebar current groups', () => {
bench('old', () => {
mockSidebarRender(getSidebar(context.url.pathname, route.locale), (entry) =>
flattenSidebar(entry.entries).some((i) => i.isCurrent)
);
});
bench('new', () => {
mockSidebarRender(getSidebar(context.url.pathname, route.locale), sidebarGroupHasCurrent);
});
});I’ll add just the new one to this PR so we test it in CI too. Edit: I will note that I think real-world impacts may be small. For example, if I complicate the “mock” render slightly more to mimic a bit some of the other attributes and stuff we do in the Updated mock render functionfunction mockSidebarRender(sublist: SidebarEntry[], getOpen: (entry: SidebarGroup) => boolean) {
sublist.map((entry) => {
if (entry.type === 'link') {
return {
href: entry.href,
'aria-current': entry.isCurrent ? 'page' : undefined,
...entry.attrs,
children: [entry.label, entry.badge],
};
} else {
return {
open: getOpen(entry) || !entry.collapsed,
children: [mockSidebarRender(entry.entries, getOpen), entry.label, entry.badge],
};
}
});
}And none of this is accounting for actual render time on Astro’s side, so I guess it’ll be more of a marginal gain. ~16 µs => ~10 µs per page, so a gain of ~6 µs (which is consistent between both benchmarks). That means you need 150k+ pages to see a 1 second speed-up maybe? But it all needs caveats because these micro-benchmarks may not mirror real-world performance behaviour. For example, maybe the |
HiDeoo
left a comment
There was a problem hiding this comment.
Not quite sure if this was ready for review or not so I went ahead and submitted one anyway, I can still do a new review later if needed.
The changes looks good and make sense to me. Like you mentioned, I expect this to be pretty marginal in terms of impact.
Note that this would need a changeset once ready for release.
Once merged, I'll investigate the change suggestions I made and evaluate if they are worth it or not as a follow-up.
Co-authored-by: HiDeoo <494699+HiDeoo@users.noreply.github.com>
Co-Authored-By: HiDeoo <494699+HiDeoo@users.noreply.github.com>
|
OK, I added a changeset and I think this ready for a final (hopefully) review! I backported the benchmark onto |
|
Another amazing improvement 🚀 |
delucis
left a comment
There was a problem hiding this comment.
Thanks for the initial investigation and implementation @ematipico 🙌
* main: (34 commits) [ci] format docs: add Large Print to community themes (withastro#4223) [ci] release (withastro#4217) Fix file icons generator (withastro#4215) perf: avoid repeated sidebar subtree scans (withastro#4149) Add benchmark for sidebar sublist processing (withastro#4216) i18n: add freshness to Lunaria dashboard (withastro#4192) [ci] release (withastro#4212) Fix toc highlighting potential page freeze (withastro#4211) i18n(de): update `themes.mdx` (withastro#4207) i18n(ko-KR): update `themes` (withastro#4210) docs: add Pinlyx Docs to the showcase (withastro#4208) [ci] release (withastro#4206) add Fluxer icon (withastro#4201) docs(themes): add Dracula for Starlight (withastro#4205) Update Lunaria action version in workflow (withastro#4204) docs: deploy docs with built Starlight (withastro#4187) ci: fix lunaria workflow (withastro#4203) docs: fix repository link in contributing guide (withastro#4202) Update Lunaria to v0.2 (withastro#4200) ...
This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [@astrojs/starlight](https://starlight.astro.build) ([source](https://github.com/withastro/starlight/tree/HEAD/packages/starlight)) | [`0.42.3` → `0.42.4`](https://renovatebot.com/diffs/npm/@astrojs%2fstarlight/0.42.3/0.42.4) |  |  | --- ### Release Notes <details> <summary>withastro/starlight (@​astrojs/starlight)</summary> ### [`v0.42.4`](https://github.com/withastro/starlight/blob/HEAD/packages/starlight/CHANGELOG.md#0424) [Compare Source](https://github.com/withastro/starlight/compare/@astrojs/starlight@0.42.3...@astrojs/starlight@0.42.4) ##### Patch Changes - [#​4149](withastro/starlight#4149) [`fa10e87`](withastro/starlight@fa10e87) Thanks [@​ematipico](https://github.com/ematipico)! - Optimizes rendering of large nested sidebars - [#​4215](withastro/starlight#4215) [`f791de6`](withastro/starlight@f791de6) Thanks [@​HiDeoo](https://github.com/HiDeoo)! - Adds a new `seti:coffee` icon for CoffeeScript files in the `<FileTree>` component. - [#​4215](withastro/starlight#4215) [`f791de6`](withastro/starlight@f791de6) Thanks [@​HiDeoo](https://github.com/HiDeoo)! - Fixes `<FileTree>` icons for `.ejs` and `npm-debug.log` files displaying the default file icon. </details> --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC4xMTUuMTAiLCJ1cGRhdGVkSW5WZXIiOiI0NC4xMTUuMTAiLCJ0YXJnZXRCcmFuY2giOiJtYWluIiwibGFiZWxzIjpbXX0=-->

Description
The sidebar now finds all groups containing the current page in one traversal. Previously, each group repeatedly flattened its subtree, causing unnecessary work for deeply nested sidebars.
Should save some memory and a bit of milliseconds.