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
4 changes: 2 additions & 2 deletions src/app/(api)/api/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { ApiRequest, ApiResponses, Heading, Link, Note, Paragraph, Tag, TimeAgo
import WithQuicknav from '@/components/WithQuickNav';
import { Icon } from '@/icons';
import { loadMdxInfo } from '@/lib/markdown/util';
import { getActiveAncestors, getMenuItem } from '@/lib/menu/util';
import { getCanonicalActiveAncestors, getMenuItem } from '@/lib/menu/util';
import { getLastUpdated, withDefaultMetadata, withMdxMetadata } from '@/lib/metadata/util';
import { parseSchemas, toOperations } from '@/lib/swagger/parse';
import { toRouteSegments, toSlug } from '@/lib/util';
Expand Down Expand Up @@ -78,7 +78,7 @@ const Page = async ({ params }: PageProps) => {

const pathname = `${qualifiedSlug}`;
const menuData = getMenuItem('api');
const ancestors = getActiveAncestors(pathname, [menuData]);
const ancestors = getCanonicalActiveAncestors(pathname, [menuData]);
const parentTitle = ancestors.length > 1 ? ancestors[ancestors.length - 2].title : null;

if (mdxInfo) {
Expand Down
4 changes: 2 additions & 2 deletions src/app/(documentation)/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { withMdxMetadata, withDefaultMetadata, getLastUpdated } from '@/lib/metadata/util';
import { TimeAgo } from '@/components';
import { getMenuItem, getActiveAncestors } from '@/lib/menu/util';
import { getMenuItem, getCanonicalActiveAncestors } from '@/lib/menu/util';
import WithQuicknav from '@/components/WithQuickNav';
import { cx } from 'class-variance-authority';

Expand Down Expand Up @@ -51,7 +51,7 @@ const Page = async ({ params }: PageProps) => {
// 2. Original logic: Get parentTitle from the menu system.
const pathname = `/${qualifiedSlug}`;
const menuData = getMenuItem('documentation');
const ancestors = getActiveAncestors(pathname, [menuData]);
const ancestors = getCanonicalActiveAncestors(pathname, [menuData]);
const menuParentTitle = ancestors.length > 1 ? ancestors[ancestors.length - 2].title : null;

// 3. Prioritize the title from the MDX file, then fall back to the menu.
Expand Down
4 changes: 2 additions & 2 deletions src/app/(guides)/guides/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { withMdxMetadata, withDefaultMetadata, getLastUpdated } from '@/lib/metadata/util';
import { TimeAgo } from '@/components';
import { getMenuItem, getActiveAncestors } from '@/lib/menu/util';
import { getMenuItem, getCanonicalActiveAncestors } from '@/lib/menu/util';
import WithQuicknav from '@/components/WithQuickNav';
import { cx } from 'class-variance-authority';

Expand Down Expand Up @@ -50,7 +50,7 @@ const Page = async ({ params }: PageProps) => {

const pathname = `/${qualifiedSlug}`;
const menuData = getMenuItem('guides');
const ancestors = getActiveAncestors(pathname, [menuData]);
const ancestors = getCanonicalActiveAncestors(pathname, [menuData]);
const parentTitle = ancestors.length > 1 ? ancestors[ancestors.length - 2].title : null;

return (
Expand Down
19 changes: 3 additions & 16 deletions src/app/navigation.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

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

import { usePathname } from 'next/navigation';

Expand Down Expand Up @@ -31,24 +31,11 @@ export const useNavigation = () => {
export const NavigationEvents = () => {
const pathname = usePathname();
const { setNavigationState } = useNavigation();
const previousPathname = useRef(pathname);

// Close the navigation when the pathname changes
// Close the navigation when the pathname changes.
// Scroll-to-top and hash scrolling run in `useHashScroll` inside `WithQuicknav` only.
useEffect(() => {
setNavigationState('closed');

if (previousPathname.current !== pathname) {
// Disable smooth scroll so the top-reset is instant, then restore it
document.documentElement.style.scrollBehavior = 'auto';
window.requestAnimationFrame(() => {
window.scrollTo(0, 0);
window.requestAnimationFrame(() => {
document.documentElement.style.scrollBehavior = '';
});
});
}

previousPathname.current = pathname;
}, [pathname, setNavigationState]);

return null;
Expand Down
33 changes: 0 additions & 33 deletions src/components/QuickNav/QuickNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { useEffect, useState } from 'react';

import { cx } from 'class-variance-authority';
import { usePathname } from 'next/navigation';

import { quickNavContentSelector } from '@/lib/constants/quickNav';

Expand All @@ -12,26 +11,7 @@ import { useHeadingsObserver } from './useHeadingsObserver';

const headingsToObserve = ':scope > :is(h2, h3, h4, h5, h6):not([data-quick-nav-ignore])';

export const scrollToHashTarget = (hash = window.location.hash) => {
const normalizedHash = hash.replace(/^#/, '');
if (!normalizedHash) return false;

let targetId = normalizedHash;
try {
targetId = decodeURIComponent(normalizedHash);
} catch {
targetId = normalizedHash;
}

const target = document.getElementById(targetId);
if (!target) return false;

target.scrollIntoView({ block: 'start' });
return true;
};

export const QuickNav = () => {
const pathname = usePathname();
const [headings, setHeadings] = useState<Array<HeadingList>>([]);
const activeHeadline = useHeadingsObserver(
quickNavContentSelector,
Expand All @@ -40,19 +20,6 @@ export const QuickNav = () => {
1,
);

useEffect(() => {
if (!headings.length) return;

const queueHashScroll = () => window.requestAnimationFrame(() => scrollToHashTarget());

queueHashScroll();
window.addEventListener('hashchange', queueHashScroll);

return () => {
window.removeEventListener('hashchange', queueHashScroll);
};
}, [headings.length, pathname]);

useEffect(() => {
const contentArea = document.querySelector(quickNavContentSelector);
if (!(contentArea instanceof HTMLElement)) return;
Expand Down
80 changes: 80 additions & 0 deletions src/components/QuickNav/hashScroll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/** @jest-environment jsdom */

import { scheduleDeferredScrollToTop, scheduleHashScroll, scheduleScrollToTop } from './hashScroll';

describe('scheduleHashScroll', () => {
beforeEach(() => {
jest.useFakeTimers();
document.body.innerHTML = '';
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
callback(0);
return 0;
});
});

afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});

test('skips post-layout retry when the target is missing', () => {
const scrollIntoView = jest.fn();
const target = document.createElement('h2');
target.id = 'developer-tools';
target.scrollIntoView = scrollIntoView;
document.body.appendChild(target);

scheduleHashScroll('#missing');
jest.runAllTicks();
jest.advanceTimersByTime(350);

expect(scrollIntoView).not.toHaveBeenCalled();
});

test('scheduleScrollToTop scrolls to the document top with a post-layout retry', () => {
const scrollTo = jest.spyOn(window, 'scrollTo').mockImplementation(() => {});

scheduleScrollToTop();
jest.runAllTicks();
expect(scrollTo).toHaveBeenCalled();

const callsAfterFrames = scrollTo.mock.calls.length;
jest.advanceTimersByTime(350);
expect(scrollTo.mock.calls.length).toBeGreaterThan(callsAfterFrames);

scrollTo.mockRestore();
});

test('scheduleDeferredScrollToTop respects hash presence after the frame', () => {
const scrollTo = jest.spyOn(window, 'scrollTo').mockImplementation(() => {});

scheduleDeferredScrollToTop(() => true);
jest.runAllTicks();
expect(scrollTo).not.toHaveBeenCalled();

scheduleDeferredScrollToTop(() => false);
jest.runAllTicks();
expect(scrollTo).toHaveBeenCalled();

scrollTo.mockRestore();
});

test('cancels a scheduled post-layout retry', () => {
const scrollIntoView = jest.fn();
const target = document.createElement('h2');
target.id = 'developer-tools';
target.scrollIntoView = scrollIntoView;
document.body.appendChild(target);

const cancel = scheduleHashScroll('#developer-tools');
jest.runAllTicks();

const callsAfterFrames = scrollIntoView.mock.calls.length;
expect(callsAfterFrames).toBeGreaterThanOrEqual(1);

cancel();
jest.advanceTimersByTime(350);

expect(scrollIntoView).toHaveBeenCalledTimes(callsAfterFrames);
});
});
169 changes: 169 additions & 0 deletions src/components/QuickNav/hashScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Copyright 2026 Cloudsmith Ltd

const FRAME_RETRIES = 5;
Comment thread
niallobrien marked this conversation as resolved.
/** Match Sidenav `openCloseTransition.duration` (0.35s). */
const POST_LAYOUT_MS = 350;

const withInstantScroll = (scroll: () => void) => {
const { documentElement } = document;
const previousScrollBehavior = documentElement.style.scrollBehavior;
documentElement.style.scrollBehavior = 'auto';

scroll();

documentElement.style.scrollBehavior = previousScrollBehavior;
};

export const scrollToDocumentTop = () => {
withInstantScroll(() => {
window.scrollTo(0, 0);
});
};

export const scrollToHashTarget = (hash = window.location.hash) => {
const normalizedHash = hash.replace(/^#/, '');
if (!normalizedHash) return false;

let targetId = normalizedHash;
try {
targetId = decodeURIComponent(normalizedHash);
} catch {
targetId = normalizedHash;
}

const target = document.getElementById(targetId);
if (!target) return false;

withInstantScroll(() => {
target.scrollIntoView({ block: 'start' });
});

return true;
};

export type CancelHashScroll = () => void;

type PostLayoutRetry = 'always' | 'onAttemptSuccess';

/**
* Runs `attempt` across animation frames and optionally after sidenav layout animations.
* Returns a cancel function; call it when the hash or route changes before pending work runs.
*/
const scheduleScrollRetries = (attempt: () => boolean, postLayout: PostLayoutRetry): CancelHashScroll => {
let cancelled = false;
let rafId = 0;
let timeoutId = 0;

const cancel = () => {
cancelled = true;
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
if (timeoutId) {
window.clearTimeout(timeoutId);
timeoutId = 0;
}
Comment thread
niallobrien marked this conversation as resolved.
};

const runAttempt = () => {
if (cancelled) {
return false;
}

return attempt();
};

const schedulePostLayoutRetry = () => {
if (cancelled || timeoutId) {
return;
}

timeoutId = window.setTimeout(() => {
timeoutId = 0;
runAttempt();
}, POST_LAYOUT_MS);
};

queueMicrotask(() => {
if (cancelled) {
return;
}

const retryFrames = (remaining: number) => {
if (cancelled) {
return;
}

const succeeded = runAttempt();

if (postLayout === 'onAttemptSuccess' && succeeded) {
schedulePostLayoutRetry();
return;
}

if (remaining > 0) {
rafId = window.requestAnimationFrame(() => retryFrames(remaining - 1));
return;
Comment thread
niallobrien marked this conversation as resolved.
}

if (postLayout === 'always') {
schedulePostLayoutRetry();
}
};

retryFrames(FRAME_RETRIES);
});

return cancel;
};

/**
* Scrolls to the document top, retrying across frames and after sidenav layout animations.
* Use when the hash is cleared on the current page (e.g. switching hash-anchor menu sections).
*/
export const scheduleScrollToTop = (): CancelHashScroll => {
return scheduleScrollRetries(() => {
scrollToDocumentTop();
return true;
}, 'always');
};

/**
* Scrolls to the hash target, retrying across frames and after sidenav layout animations.
* Sidenav expand/collapse can reflow the page after the first scroll attempt.
*
* A post-layout retry runs only when a frame retry finds the target (to correct scroll
* position after the branch animation). If the target is never found, no delayed retry runs.
*/
export const scheduleHashScroll = (hash = window.location.hash): CancelHashScroll => {
if (!hash) {
return () => {};
}

return scheduleScrollRetries(() => scrollToHashTarget(hash), 'onAttemptSuccess');
};

/**
* Defers scroll-to-top by one frame so a hash applied in the same navigation tick
* (e.g. `router.push('/page#section')`) is not overridden.
*/
export const scheduleDeferredScrollToTop = (
hasWindowHash: () => boolean = () => Boolean(window.location.hash),
): CancelHashScroll => {
let cancelled = false;
let cancelScroll: CancelHashScroll | undefined;
const rafId = window.requestAnimationFrame(() => {
if (cancelled || hasWindowHash()) {
return;
}

cancelScroll = scheduleScrollToTop();
});

return () => {
cancelled = true;
window.cancelAnimationFrame(rafId);
cancelScroll?.();
Comment thread
niallobrien marked this conversation as resolved.
};
};
Loading