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: 5 additions & 0 deletions .changeset/calm-navigation-await.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rspress/core': minor
---

Add an awaited navigation hook that resolves after the target route commits and recovers from failures or timeouts.
29 changes: 29 additions & 0 deletions packages/core/src/theme/components/Link/useLinkNavigate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, rs, test } from '@rstest/core';

rs.mock('@rspress/core/runtime', () => ({
cleanUrlByConfig: (href: string) => href.replace(/\.html(?=[?#]|$)/, ''),
isExternalUrl: (href: string) => /^https?:\/\//.test(href),
removeBase: (href: string) => href.replace(/^\/docs(?=\/|$)/, '') || '/',
withBase: (href: string) =>
href.startsWith('/docs') ? href : `/docs${href}`,
}));

rs.mock('nprogress', () => ({
default: { configure() {} },
}));

import { getAwaitedTarget } from './useLinkNavigate';

describe('getAwaitedTarget', () => {
test('matches the canonical router target', () => {
expect(getAwaitedTarget('/docs/guide.html?tab=api#types', '/current')).toBe(
'/guide?tab=api#types',
);
});

test('resolves hash-only links against the current target', () => {
expect(getAwaitedTarget('#types', '/guide?tab=api#intro')).toBe(
'/guide?tab=api#types',
);
});
});
152 changes: 140 additions & 12 deletions packages/core/src/theme/components/Link/useLinkNavigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,20 @@ import {
startTransition as reactStartTransition,
type TransitionStartFunction,
useCallback,
useEffect,
useRef,
} from 'react';

nprogress.configure({ showSpinner: false });

const NAVIGATION_TIMEOUT_MS = 10_000;

interface PendingNavigation {
cancel(error: Error): void;
resolve(): void;
target: string;
}

function isAbsoluteUrl(url: string): boolean {
return url.startsWith('/');
}
Expand Down Expand Up @@ -54,7 +64,8 @@ export function getHref(href: string): {
}

if (linkType === 'relative' && !import.meta.env.SSR) {
withBaseHref = new URL(href, window.location.href).pathname;
const url = new URL(href, window.location.href);
withBaseHref = `${url.pathname}${url.search}${url.hash}`;
} else {
withBaseHref = withBase(cleanUrlByConfig(href));
}
Expand All @@ -63,6 +74,13 @@ export function getHref(href: string): {
return { withBaseHref, removeBaseHref, linkType };
}

export function getAwaitedTarget(href: string, currentTarget: string): string {
const { linkType, removeBaseHref } = getHref(href);
return linkType === 'hashOnly'
? `${currentTarget.split('#')[0]}${href}`
: removeBaseHref;
}

/**
* For import { Link } from '@rspress/core/theme';
* useNavigate with preload logic
Expand All @@ -73,14 +91,15 @@ export function useLinkNavigate(
}: { startTransition?: TransitionStartFunction } = {
startTransition: reactStartTransition,
},
): (href: string) => Promise<void> {
): (href: string, options?: { signal?: AbortSignal }) => Promise<void> {
const { pathname: currPagePathname } = useLocation();
const navigate = useNavigateInner();
const { site } = useSite();
const useTransitions = site?.route?.useTransitions;

return useCallback(
async (href: string) => {
async (href: string, { signal }: { signal?: AbortSignal } = {}) => {
signal?.throwIfAborted();
const { linkType, removeBaseHref, withBaseHref } = getHref(href);
if (linkType === 'external' || linkType === 'hashOnly') {
window.location.assign(href);
Expand All @@ -97,15 +116,21 @@ export function useLinkNavigate(
const timer = setTimeout(() => {
nprogress.start();
}, 200);
const data = await initPageData(removeBaseHref);
warmPageData(removeBaseHref, data);
clearTimeout(timer);
nprogress.done();
try {
const data = await initPageData(removeBaseHref);
signal?.throwIfAborted();
warmPageData(removeBaseHref, data);
} finally {
clearTimeout(timer);
nprogress.done();
}
} else {
signal?.throwIfAborted();
window.location.assign(withBaseHref);
return;
}
}
signal?.throwIfAborted();
if (isTransitionable) {
startTransition(() => {
return navigate(removeBaseHref, { replace: false });
Expand All @@ -115,12 +140,115 @@ export function useLinkNavigate(
}
};

if (isTransitionable) {
startTransition(preloadChunkThenNavigate);
} else {
preloadChunkThenNavigate();
}
await preloadChunkThenNavigate();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the preload inside the supplied transition

When route.useTransitions is enabled and Link receives useTransition().startTransition, awaiting preloadChunkThenNavigate() directly moves the potentially slow initPageData phase outside that transition. Consequently, the documented isPending state remains false until preloading finishes, so loading feedback disappears during most of a slow navigation; this reverses the async-transition handling intentionally added in commit 2587f69. Preserve the outer transition around the asynchronous preload while separately settling the promise returned by useLinkNavigate.

Useful? React with 👍 / 👎.

},
[useTransitions, currPagePathname, navigate, startTransition],
);
}

/**
* Navigate through the Rspress router and resolve after the target location
* commits. Calls are serialized and failed or timed-out attempts do not
* block later calls.
*/
export function useAwaitedLinkNavigate(
committedTarget?: string,
): (href: string) => Promise<void> {
Comment on lines +149 to +156
const navigate = useLinkNavigate();
const { pathname, search, hash } = useLocation();
const currentTarget =
committedTarget ?? `${removeBase(pathname)}${search}${hash}`;
const currentTargetRef = useRef(currentTarget);
const activeRef = useRef(true);
const pendingRef = useRef<PendingNavigation | null>(null);
const queueRef = useRef<Promise<void>>(Promise.resolve());

useEffect(() => {
currentTargetRef.current = currentTarget;
const pending = pendingRef.current;
if (pending?.target === currentTarget) {
pendingRef.current = null;
pending.resolve();
}
}, [currentTarget]);

useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
pendingRef.current?.cancel(new Error('Navigation was interrupted'));
pendingRef.current = null;
};
}, []);

const navigateAndWait = useCallback(
async (href: string, target: string) => {
if (currentTargetRef.current === target) {
await navigate(href);
return;
}
Comment on lines +184 to +189

let pending!: PendingNavigation;
const controller = new AbortController();
const completion = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
pending.cancel(
new Error(
`Navigation did not complete within ${NAVIGATION_TIMEOUT_MS}ms`,
),
);
}, NAVIGATION_TIMEOUT_MS);
pending = {
cancel(error) {
clearTimeout(timeout);
controller.abort(error);
reject(error);
},
resolve() {
clearTimeout(timeout);
resolve();
},
target,
};
pendingRef.current = pending;
});

try {
await Promise.all([
navigate(href, { signal: controller.signal }),
completion,
]);
} catch (error) {
pending.cancel(
error instanceof Error
? error
: new Error('Navigation failed', { cause: error }),
);
if (pendingRef.current === pending) {
pendingRef.current = null;
}
throw error;
}
},
[navigate],
);

return useCallback(
(target: string) => {
const queued = queueRef.current
.catch(() => undefined)
.then(() => {
if (!activeRef.current) {
throw new Error('Navigation was interrupted');
}
return navigateAndWait(
target,
getAwaitedTarget(target, currentTargetRef.current),
);
});
queueRef.current = queued;
return queued;
},
[navigateAndWait],
);
Comment on lines +236 to +253
}
5 changes: 4 additions & 1 deletion packages/core/src/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export { HoverGroup, type HoverGroupProps } from './components/HoverGroup';
export { useHoverGroup } from './components/HoverGroup/useHoverGroup';
export { LastUpdated } from './components/LastUpdated/index';
export { Link, type LinkProps } from './components/Link/index';
export { useLinkNavigate } from './components/Link/useLinkNavigate';
export {
useAwaitedLinkNavigate,
useLinkNavigate,
} from './components/Link/useLinkNavigate';
export {
LlmsContainer,
type LlmsContainerProps,
Expand Down
Loading