Skip to content

feat: add sidebar scroll position persistence#75

Open
ubay1 wants to merge 5 commits into
nodejs:mainfrom
ubay1:fix/persist-sidebar-scroll-position
Open

feat: add sidebar scroll position persistence#75
ubay1 wants to merge 5 commits into
nodejs:mainfrom
ubay1:fix/persist-sidebar-scroll-position

Conversation

@ubay1

@ubay1 ubay1 commented May 21, 2026

Copy link
Copy Markdown
  • Add useScroll hook with debounced scroll event handling
  • Add useScrollToElement hook to save/restore scroll position via localStorage and in-memory NavigationStateContext
  • Add NavigationStateProvider context for session-level state
  • Update Sidebar component to use useScrollToElement with a useLayoutEffect workaround (SideBar lacks forwardRef support)

#74 fix sidebar scroll position persistence

- Add `useScroll` hook with debounced scroll event handling
- Add `useScrollToElement` hook to save/restore scroll position
  via localStorage and in-memory NavigationStateContext
- Add `NavigationStateProvider` context for session-level state
- Update `Sidebar` component to use `useScrollToElement` with
  a `useLayoutEffect` workaround (SideBar lacks forwardRef support)
@ubay1
ubay1 requested a review from a team as a code owner May 21, 2026 01:13
@vercel

vercel Bot commented May 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nodejs-learn Ready Ready Preview Jul 5, 2026 2:01am

Request Review

@cursor

cursor Bot commented May 21, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Navigation UX and client-side storage only; the aside query workaround could mis-target if multiple asides exist on a page.

Overview
Adds sidebar scroll position persistence so the learn docs nav keeps its scroll offset when users move between pages or refresh.

The app layout is wrapped in a new NavigationStateProvider that holds in-memory scroll state for the session. useScroll debounces scroll events on a DOM ref; useScrollToElement restores position on mount (context first, then localStorage under navigationState:sidebar) and saves {x, y} on scroll, with safe fallbacks when storage is unavailable.

The Sidebar wires this up via useScrollToElement('sidebar', …). Because the shared SideBar UI component does not forward refs, a useLayoutEffect assigns the ref from document.querySelector('aside') so the scroll listener attaches before useScroll runs.

RemoteLoadableBanner is removed from the main layout in the same change.

Reviewed by Cursor Bugbot for commit 8e57f94. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

Copy link
Copy Markdown

👋 Codeowner Review Request

The following codeowners have been identified for the changed files:

Team reviewers: @nodejs/nodejs-website

Please review the changes when you have a chance. Thank you! 🙏

Comment thread hooks/useScrollToElement.js Outdated
Comment thread hooks/useScrollToElement.js Outdated
Comment thread providers/navigationStateProvider.jsx
Comment thread hooks/useScrollToElement.js Outdated
Comment thread hooks/useScrollToElement.js
@RushilJalal

Copy link
Copy Markdown

Kindly merge asap, this is something which is actively causing problems for me while reading docs.

@AugustinMauroy

Copy link
Copy Markdown
Member

I didn't recall correctly @nodejs/nodejs-website but don't we solve that on ui component ?

@ubay1

ubay1 commented Jul 5, 2026

Copy link
Copy Markdown
Author

Hi @AugustinMauroy,

Just to clarify, since the website currently triggers a full browser reload on page navigation instead of behaving like a Single Page Application (SPA), all component states are completely wiped out between pages.

Because of this full reload, we can't solve it purely within a UI component's lifecycle or state. We actually need to persist the scroll position across sessions using localStorage, which is why I implemented it using a custom hook to handle this side-effect.

Let me know what you think!

Signed-off-by: Ubay Dillah <33889018+ubay1@users.noreply.github.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want fixes drafted automatically? Bugbot Autofix can create code changes for findings. A team admin can enable Autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8e57f94. Configure here.

@@ -1,13 +1,13 @@
import TableOfContents from '@node-core/ui-components/Common/TableOfContents';
import Article from '@node-core/ui-components/Containers/Article';
import RemoteLoadableBanner from '@node-core/doc-kit/src/generators/web/ui/components/AnnouncementBanner/RemoteLoadableBanner';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Announcement banner removed accidentally

Medium Severity

The layout change drops RemoteLoadableBanner and its import while the PR only describes sidebar scroll persistence. Learn pages no longer render the remote announcement banner that previously appeared site-wide.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8e57f94. Configure here.

Comment thread hooks/useScroll.js
// Clear any pending debounced calls
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debounced scroll not saved on exit

Medium Severity

When the scroll listener unmounts, cleanup clears the debounce timer without persisting the latest scrollTop. A user who scrolls the sidebar and follows a doc link within the debounce window keeps a stale position in localStorage, so restore on the next page can be wrong.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8e57f94. Configure here.

// listener, sidebarRef.current already points to the real <aside> element.
useLayoutEffect(() => {
sidebarRef.current = document.querySelector('aside');
}, []);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrong aside targeted globally

Medium Severity

Scroll persistence binds to document.querySelector('aside'), the first aside in the document. The layout renders NavBar (with a mobile navigation toggler) before the doc SideBar, so scroll save/restore may attach to the navbar drawer instead of the learn sidebar.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8e57f94. Configure here.

@MattIPv4 MattIPv4 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 Can't say I'm overly keen on this as a solution to the problem stated in the issue -- this feels overly complex and thrashes localStorage.

Can't we solve the original issue stated by having the sidebar always scroll the active item into view on load? This should maybe be done upstream in doc-kit, as https://beta.docs.nodejs.org suffers from the same issue?

@ubay1

ubay1 commented Jul 19, 2026

Copy link
Copy Markdown
Author

👀 Can't say I'm overly keen on this as a solution to the problem stated in the issue -- this feels overly complex and thrashes localStorage.

Can't we solve the original issue stated by having the sidebar always scroll the active item into view on load? This should maybe be done upstream in doc-kit, as https://beta.docs.nodejs.org suffers from the same issue?

this only takes up a little localstorage, it seems fine. but if you want to change it from dock-kit, that would be better.

@MattIPv4

Copy link
Copy Markdown
Member

👍 I didn't complain about the size of what's being put in localStorage, I am concerned with the fact that this thrashes writes to localStorage and feels like a rather hacky solution to emulate SPA behaviour.

Yes, I'd encourage you to open a PR in doc-kit instead, to have it scroll to the active item on page load, or perhaps ui-components (though my $0.02 is that this is more of an implementation detail that should live in doc-kit, rather than in the component itself in ui-components).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants