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
18 changes: 11 additions & 7 deletions apps/site/components/withSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
'use client';

import Sidebar from '@node-core/ui-components/Containers/Sidebar';
import { usePathname } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useTranslations } from 'next-intl';
import { useRef } from 'react';

import Link from '#site/components/Link';
import { useClientContext } from '#site/hooks/client';
import { useClientContext, useScrollToElement } from '#site/hooks/client';
import { useSiteNavigation } from '#site/hooks/generic';
import { useRouter } from '#site/navigation.mjs';
import { useRouter, usePathname } from '#site/navigation.mjs';

import type { NavigationKeys } from '#site/types';
import type { RichTranslationValues } from 'next-intl';
Expand All @@ -21,14 +21,17 @@ type WithSidebarProps = {
const WithSidebar: FC<WithSidebarProps> = ({ navKeys, context, ...props }) => {
const { getSideNavigation } = useSiteNavigation();
const pathname = usePathname()!;
const locale = useLocale();
const t = useTranslations();
const { push } = useRouter();
const { frontmatter } = useClientContext();
const sidebarRef = useRef<HTMLElement>(null);
const sideNavigation = getSideNavigation(navKeys, context);

// Preserve sidebar scroll position across navigations
useScrollToElement('sidebar', sidebarRef);

const mappedSidebarItems =
// If there's only a single navigation key, use it's sub-items
// If there's only a single navigation key, use its sub-items
// as our navigation.
(navKeys.length === 1 ? sideNavigation[0][1].items : sideNavigation).map(
([, { label, items }]) => ({
Expand All @@ -39,8 +42,9 @@ const WithSidebar: FC<WithSidebarProps> = ({ navKeys, context, ...props }) => {

return (
<Sidebar
ref={sidebarRef}
groups={mappedSidebarItems}
pathname={pathname.replace(`/${locale}`, '')}
Copy link
Member

Choose a reason for hiding this comment

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

Wondering why this got changed? 👀

Copy link
Contributor Author

@malav2110 malav2110 Jan 9, 2026

Choose a reason for hiding this comment

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

It was put in while I was exploring the solution. Reverted back.

Actually, Changed next/navigation's usePathname to next-intl version from navigation.mjs. The next-intl version already returns pathnames without the locale prefix, so .replace() call is not needed.

pathname={pathname}
title={t('components.common.sidebar.title')}
placeholder={frontmatter?.title}
onSelect={push}
Expand Down
145 changes: 145 additions & 0 deletions apps/site/hooks/client/__tests__/useScrollToElement.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, it, mock } from 'node:test';
import assert from 'node:assert/strict';

import useScrollToElement from '#site/hooks/client/useScrollToElement.js';
import { NavigationStateContext } from '#site/providers/navigationStateProvider';

describe('useScrollToElement', () => {
let mockElement;
let mockRef;
let navigationState;

beforeEach(() => {
navigationState = {};

mockElement = {
scrollTop: 0,
scrollLeft: 0,
scroll: mock.fn(),
addEventListener: mock.fn(),
removeEventListener: mock.fn(),
};

mockRef = { current: mockElement };
});

afterEach(() => {
mock.reset();
});

it('should handle scroll restoration with various scenarios', () => {
const wrapper = ({ children }) => (
<NavigationStateContext.Provider value={navigationState}>
{children}
</NavigationStateContext.Provider>
);

// Should restore scroll position on mount if saved state exists
navigationState.sidebar = { x: 100, y: 200 };
const { unmount: unmount1 } = renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper });

assert.equal(mockElement.scroll.mock.callCount(), 1);
assert.deepEqual(mockElement.scroll.mock.calls[0].arguments, [
{ top: 200, behavior: 'auto' },
]);

unmount1();
mock.reset();
mockElement.scroll = mock.fn();

// Should not restore if no saved state exists
navigationState = {};
const { unmount: unmount2 } = renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper });
assert.equal(mockElement.scroll.mock.callCount(), 0);

unmount2();
mock.reset();
mockElement.scroll = mock.fn();

// Should not restore if current position matches saved state
navigationState.sidebar = { x: 0, y: 0 };
mockElement.scrollTop = 0;
const { unmount: unmount3 } = renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper });
assert.equal(mockElement.scroll.mock.callCount(), 0);

unmount3();
mock.reset();
mockElement.scroll = mock.fn();

// Should restore scroll to element that was outside viewport (deep scroll)
navigationState.sidebar = { x: 0, y: 1500 };
mockElement.scrollTop = 0;
renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper });

assert.equal(mockElement.scroll.mock.callCount(), 1);
assert.deepEqual(mockElement.scroll.mock.calls[0].arguments, [
{ top: 1500, behavior: 'auto' },
]);
});

it('should persist and restore scroll position across navigation', async () => {
const wrapper = ({ children }) => (
<NavigationStateContext.Provider value={navigationState}>
{children}
</NavigationStateContext.Provider>
);

// First render: user scrolls to position 800
const { unmount } = renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper });

const scrollHandler = mockElement.addEventListener.mock.calls[0].arguments[1];
mockElement.scrollTop = 800;
mockElement.scrollLeft = 0;
scrollHandler();

// Wait for debounce
await new Promise(resolve => setTimeout(resolve, 350));

// Position should be saved
assert.deepEqual(navigationState.sidebar, { x: 0, y: 800 });

// Simulate navigation (unmount)
unmount();

// Simulate navigation back (remount with element at top)
mockElement.scrollTop = 0;
mock.reset();
mockElement.scroll = mock.fn();
mockElement.addEventListener = mock.fn();
mockElement.removeEventListener = mock.fn();
mockRef.current = mockElement;

renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper });

// Should restore to position 800
assert.equal(mockElement.scroll.mock.callCount(), 1);
assert.deepEqual(mockElement.scroll.mock.calls[0].arguments, [
{ top: 800, behavior: 'auto' },
]);

// Also test that scroll position is saved to navigation state during scroll
mock.reset();
mockElement.addEventListener = mock.fn();
mockElement.scroll = mock.fn();
navigationState = {};

renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper });

// Get the scroll handler that was registered
const scrollHandler2 = mockElement.addEventListener.mock.calls[0].arguments[1];

// Simulate scroll
mockElement.scrollTop = 150;
mockElement.scrollLeft = 50;

// Call the handler
scrollHandler2();

// Wait for debounce (default 300ms)
await new Promise(resolve => setTimeout(resolve, 350));

// Check that navigation state was updated
assert.deepEqual(navigationState.sidebar, { x: 50, y: 150 });
});
});
2 changes: 2 additions & 0 deletions apps/site/hooks/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { default as useDetectOS } from './useDetectOS';
export { default as useMediaQuery } from './useMediaQuery';
export { default as useClientContext } from './useClientContext';
export { default as useScrollToElement } from './useScrollToElement';
export { default as useScroll } from './useScroll';
63 changes: 63 additions & 0 deletions apps/site/hooks/client/useScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
'use client';

import { useEffect, useRef } from 'react';

import type { RefObject } from 'react';

type ScrollPosition = {
x: number;
y: number;
};

type UseScrollOptions = {
debounceTime?: number;
onScroll?: (position: ScrollPosition) => void;
};

// Custom hook to handle scroll events with optional debouncing
const useScroll = <T extends HTMLElement>(
ref: RefObject<T | null>,
{ debounceTime = 300, onScroll }: UseScrollOptions = {}
) => {
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);

useEffect(() => {
// Get the current element
const element = ref.current;

// Return early if no element or onScroll callback is provided
if (!element || !onScroll) {
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) {
onScroll({
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);
}
};
}, [ref, onScroll, debounceTime]);
};

export default useScroll;
47 changes: 47 additions & 0 deletions apps/site/hooks/client/useScrollToElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client';

import { useContext, useEffect } from 'react';

import { NavigationStateContext } from '#site/providers/navigationStateProvider';

import type { RefObject } from 'react';

import useScroll from './useScroll';

const useScrollToElement = <T extends HTMLElement>(
id: string,
ref: RefObject<T | null>,
debounceTime = 300
) => {
const navigationState = useContext(NavigationStateContext);

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

// Restore scroll position if saved state exists
const savedState = navigationState[id];

// Scroll only if the saved position differs from current
if (savedState && savedState.y !== ref.current.scrollTop) {
ref.current.scroll({ top: savedState.y, behavior: 'auto' });
}
// navigationState is intentionally excluded
// it's a stable object reference that doesn't need to trigger re-runs
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, ref]);

// Save scroll position on scroll
const handleScroll = (position: { x: number; y: number }) => {
// Save the current scroll position in the navigation state
const state = navigationState as Record<string, { x: number; y: number }>;
state[id] = position;
};

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

export default useScrollToElement;
2 changes: 2 additions & 0 deletions apps/site/hooks/server/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { default as useClientContext } from './useClientContext';
export { default as useScrollToElement } from './useScrollToElement';
export { default as useScroll } from './useScroll';
5 changes: 5 additions & 0 deletions apps/site/hooks/server/useScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const useScroll = () => {
throw new Error('Attempted to call useScroll from RSC');
};

export default useScroll;
5 changes: 5 additions & 0 deletions apps/site/hooks/server/useScrollToElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const useScrollToElement = () => {
throw new Error('Attempted to call useScrollToElement from RSC');
};

export default useScrollToElement;
2 changes: 1 addition & 1 deletion packages/ui-components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@node-core/ui-components",
"version": "1.5.3",
"version": "1.5.4",
"type": "module",
"exports": {
"./*": [
Expand Down
Loading