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
10 changes: 9 additions & 1 deletion apps/site/components/withNavBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import NavBar from '@node-core/ui-components/Containers/NavBar';
import styles from '@node-core/ui-components/Containers/NavBar/index.module.css';
import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub';
import { availableLocales } from '@node-core/website-i18n';
import classNames from 'classnames';
import dynamic from 'next/dynamic';
import { useLocale, useTranslations } from 'next-intl';
import { useTheme } from 'next-themes';
Expand All @@ -16,6 +17,7 @@ import SearchButton from '#site/components/Common/Searchbox';
import Link from '#site/components/Link';
import WithBanner from '#site/components/withBanner';
import WithNodejsLogo from '#site/components/withNodejsLogo';
import { useScrollDirection } from '#site/hooks/client';
import { useSiteNavigation } from '#site/hooks/generic';
import { useRouter, usePathname } from '#site/navigation.mjs';

Expand All @@ -42,11 +44,17 @@ const WithNavBar: FC = () => {

const locale = useLocale();

const scrollDirection = useScrollDirection();

const changeLanguage = (locale: SimpleLocaleConfig) =>
replace(pathname!, { locale: locale.code });

return (
<div>
<div
className={classNames(styles.navBarWrapper, {
[styles.hidden]: scrollDirection === 'down',
})}
>
<SkipToContentButton>
{t('components.common.skipToContent')}
</SkipToContentButton>
Expand Down
95 changes: 95 additions & 0 deletions apps/site/hooks/client/__tests__/useScrollDirection.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import { afterEach, beforeEach, describe, it } from 'node:test';

import { renderHook, act } from '@testing-library/react';

import useScrollDirection from '#site/hooks/client/useScrollDirection.js';

describe('useScrollDirection', () => {
let scrollY;
let originalRAF;

beforeEach(() => {
scrollY = 0;

Object.defineProperty(window, 'scrollY', {
get: () => scrollY,
configurable: true,
});

originalRAF = window.requestAnimationFrame;
Object.defineProperty(window, 'requestAnimationFrame', {
value: cb => {
cb();
return 1;
},
writable: true,
configurable: true,
});
});

afterEach(() => {
window.requestAnimationFrame = originalRAF;
});
Comment on lines +12 to +33
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

This test overrides window.scrollY via Object.defineProperty but doesn't restore the original property descriptor in afterEach. That can leak into later tests and create order-dependent failures. Capture the original descriptor/value in beforeEach and restore it in afterEach (similar to how requestAnimationFrame is restored).

Copilot uses AI. Check for mistakes.

it('should return null initially (at top of page)', () => {
const { result } = renderHook(() => useScrollDirection());
assert.equal(result.current, null);
});

it('should return "down" when scrolling down past threshold', () => {
const { result } = renderHook(() => useScrollDirection());

act(() => {
scrollY = 100;
window.dispatchEvent(new Event('scroll'));
});

assert.equal(result.current, 'down');
});

it('should return "up" when scrolling up past threshold', () => {
const { result } = renderHook(() => useScrollDirection());

act(() => {
scrollY = 100;
window.dispatchEvent(new Event('scroll'));
});

act(() => {
scrollY = 50;
window.dispatchEvent(new Event('scroll'));
});

assert.equal(result.current, 'up');
});

it('should not change direction for scroll less than threshold', () => {
const { result } = renderHook(() => useScrollDirection());

act(() => {
scrollY = 5;
window.dispatchEvent(new Event('scroll'));
});

assert.equal(result.current, null);
});

it('should return null when scrolling back to top', () => {
const { result } = renderHook(() => useScrollDirection());

act(() => {
scrollY = 100;
window.dispatchEvent(new Event('scroll'));
});

assert.equal(result.current, 'down');

act(() => {
scrollY = 0;
window.dispatchEvent(new Event('scroll'));
});

assert.equal(result.current, null);
});
});
1 change: 1 addition & 0 deletions apps/site/hooks/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { default as useMediaQuery } from './useMediaQuery';
export { default as useClientContext } from './useClientContext';
export { default as useScrollToElement } from './useScrollToElement';
export { default as useScroll } from './useScroll';
export { default as useScrollDirection } from './useScrollDirection';
52 changes: 52 additions & 0 deletions apps/site/hooks/client/useScrollDirection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
'use client';

import { useState, useEffect, useRef } from 'react';

type ScrollDirection = 'up' | 'down' | null;

const SCROLL_THRESHOLD = 10;

const useScrollDirection = (): ScrollDirection => {
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>(null);
const lastScrollY = useRef(0);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Incorrect direction on restored scroll position

Medium Severity

lastScrollY is initialized to 0 and never synced with the actual window.scrollY when the effect mounts. If the browser restores a non-zero scroll position (page refresh, back-navigation), the first scroll event compares against 0 instead of the real position. This causes scrolling up from, say, scrollY=500 to 490 to be reported as 'down' (since 490 > 0), incorrectly hiding the navbar. Initializing lastScrollY.current = window.scrollY at the start of the useEffect callback would fix this.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b98e45d. Configure here.

const ticking = useRef(false);

useEffect(() => {
const updateScrollDirection = () => {
const currentScrollY = window.scrollY;

if (currentScrollY <= 0) {
setScrollDirection(null);
lastScrollY.current = currentScrollY;
Comment on lines +10 to +20
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

lastScrollY is initialized to 0 and never set to the current window.scrollY on mount. If the page loads/restores at a non-zero scroll position (e.g., back/forward cache, anchor links), the first scroll event can mis-detect direction (e.g., scrolling up can still be reported as down). Initialize lastScrollY.current from window.scrollY when the effect runs (and consider keeping scrollDirection unchanged until you have a baseline).

Copilot uses AI. Check for mistakes.
ticking.current = false;
return;
}

const diff = Math.abs(currentScrollY - lastScrollY.current);

if (diff < SCROLL_THRESHOLD) {
ticking.current = false;
return;
}

setScrollDirection(currentScrollY > lastScrollY.current ? 'down' : 'up');
lastScrollY.current = currentScrollY;
ticking.current = false;
};

const onScroll = () => {
if (!ticking.current) {
ticking.current = true;
window.requestAnimationFrame(updateScrollDirection);
}
};

window.addEventListener('scroll', onScroll, { passive: true });

return () => window.removeEventListener('scroll', onScroll);
}, []);
Comment on lines +37 to +47
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

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

The requestAnimationFrame callback can still run after the component unmounts, which can call setScrollDirection on an unmounted component. Track the RAF id and cancel it in the cleanup (or guard updates with an isMounted ref) to avoid potential React warnings in fast route transitions.

Copilot uses AI. Check for mistakes.

return scrollDirection;
};

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

export default useScrollDirection;
13 changes: 13 additions & 0 deletions packages/ui-components/src/Containers/NavBar/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,16 @@
}
}
}

.navBarWrapper {
@apply xl:sticky
xl:top-0
xl:z-50
xl:transition-transform
xl:duration-300
xl:ease-in-out;
}

.navBarWrapper.hidden {
@apply xl:-translate-y-full;
}
Loading