Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions components/Layout/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import NavBar from '../Navigation';
import MetaBar from '../Metabar';
import SideBar from '../Sidebar';
import Footer from '../Footer';
import { NavigationStateProvider } from '../../providers/navigationStateProvider';

/**
* @typedef {Object} Props
Expand All @@ -20,7 +21,7 @@ import Footer from '../Footer';
* @param {Props} props
*/
export default ({ metadata, headings, readingTime, children }) => (
<>
<NavigationStateProvider>
<Analytics basePath="/learn/_vercel" />
<SpeedInsights basePath="/learn/_vercel" />
<NavBar metadata={metadata} />
Expand All @@ -40,5 +41,5 @@ export default ({ metadata, headings, readingTime, children }) => (
</div>
</Article>
<Footer metadata={metadata} />
</>
</NavigationStateProvider>
);
34 changes: 25 additions & 9 deletions components/Sidebar/index.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import SideBar from '@node-core/ui-components/Containers/Sidebar';
import { sidebar } from '../../site.json' with { type: 'json' };
import { useRef, useLayoutEffect } from 'react';
import useScrollToElement from '../../hooks/useScrollToElement';

/** @param {string} url */
const redirect = url => (window.location.href = url);
Expand All @@ -9,12 +11,26 @@ const PrefetchLink = props => <a {...props} rel="prefetch" />;
/**
* Sidebar component for MDX documentation with page navigation
*/
export default ({ metadata }) => (
<SideBar
pathname={`/learn${metadata.path.replace('/index', '')}`}
groups={sidebar}
onSelect={redirect}
as={PrefetchLink}
title="Navigation"
/>
);
export default ({ metadata }) => {
const sidebarRef = useRef(null);

// SideBar from @node-core/ui-components does not support forwardRef,
// so ref={sidebarRef} is silently ignored. useLayoutEffect runs before
// useEffect, so by the time useScroll's effect attaches the scroll
// listener, sidebarRef.current already points to the real <aside> element.
useLayoutEffect(() => {
sidebarRef.current = document.querySelector('aside');
}, []);

useScrollToElement('sidebar', sidebarRef);

return (
<SideBar
pathname={`/learn${metadata.path.replace('/index', '')}`}
groups={sidebar}
onSelect={redirect}
as={PrefetchLink}
title="Navigation"
/>
);
};
51 changes: 51 additions & 0 deletions hooks/useScroll.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useEffect, useRef } from 'react';

// Custom hook to handle scroll events with optional debouncing
const useScroll = (ref, { debounceTime = 300, onScroll }) => {
const timeoutRef = useRef(undefined);
const onScrollRef = useRef(onScroll);

// Keep onScrollRef updated with the latest callback
useEffect(() => {
onScrollRef.current = onScroll;
}, [onScroll]);
useEffect(() => {
// Get the current element
const element = ref.current;

// Return early if no element or onScroll callback is provided
if (!element || !onScrollRef.current) {
return;
}

// Debounced scroll handler
const handleScroll = () => {
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}

// Set new timeout to call onScroll after debounceTime
timeoutRef.current = setTimeout(() => {
if (element && onScrollRef.current) {
onScrollRef.current({
x: element.scrollLeft,
y: element.scrollTop,
});
}
}, debounceTime);
};

element.addEventListener('scroll', handleScroll, { passive: true });

return () => {
element.removeEventListener('scroll', handleScroll);
// Clear any pending debounced calls
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [debounceTime]);
};

export default useScroll;
52 changes: 52 additions & 0 deletions hooks/useScrollToElement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useContext, useEffect } from 'react';

import { NavigationStateContext } from '../providers/navigationStateProvider';

import useScroll from './useScroll';

const useScrollToElement = (id, ref, debounceTime = 300) => {
const navigationState = useContext(NavigationStateContext);

// Restore scroll position on mount
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}

// Prefer in-memory context state (set during same session/SPA navigation).
// Fall back to localStorage so position is restored after a full page refresh.
let savedState = navigationState[id];

if (!savedState) {
try {
const raw = localStorage.getItem(`navigationState:${id}`);
if (raw) {
savedState = JSON.parse(raw);
// Hydrate context so it's available for the rest of the session
navigationState[id] = savedState;
}
} catch {
localStorage.removeItem(`navigationState:${id}`);
}
}

// Scroll only if the saved position differs from current
if (savedState && savedState.y !== element.scrollTop) {
element.scroll({ top: savedState.y, behavior: 'auto' });
}
}, [id]);

// Save scroll position on scroll
const handleScroll = position => {
localStorage.setItem(`navigationState:${id}`, JSON.stringify(position));
// Save the current scroll position in the navigation state
const state = navigationState;
state[id] = position;
};
Comment thread
cursor[bot] marked this conversation as resolved.

// Use the useScroll hook to handle scroll events with debouncing
useScroll(ref, { debounceTime, onScroll: handleScroll });
};

export default useScrollToElement;
15 changes: 15 additions & 0 deletions providers/navigationStateProvider.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client';

import { createContext, useRef } from 'react';

export const NavigationStateContext = createContext({});

export const NavigationStateProvider = ({children}) => {
const navigationStateRef = useRef({});

return (
<NavigationStateContext.Provider value={navigationStateRef.current}>
{children}
</NavigationStateContext.Provider>
);
Comment thread
cursor[bot] marked this conversation as resolved.
};