Skip to content
Open
Show file tree
Hide file tree
Changes from all 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;
57 changes: 57 additions & 0 deletions hooks/useScrollToElement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 => {
try {
localStorage.setItem(`navigationState:${id}`, JSON.stringify(position));
} catch {
// localStorage may be unavailable (e.g. Safari private browsing)
// or the quota may be exceeded — fall through so in-memory state
// is still updated below.
}
// Always update in-memory state regardless of localStorage availability
navigationState[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.
};