From 1e41da1d4e30fd61a29dc687c84dba654f63209e Mon Sep 17 00:00:00 2001 From: SoonIter Date: Fri, 7 Aug 2026 15:54:27 +0800 Subject: [PATCH] feat(theme/Link): support awaited navigation --- .changeset/calm-navigation-await.md | 5 + .../components/Link/useLinkNavigate.test.ts | 29 ++++ .../theme/components/Link/useLinkNavigate.ts | 152 ++++++++++++++++-- packages/core/src/theme/index.ts | 5 +- 4 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 .changeset/calm-navigation-await.md create mode 100644 packages/core/src/theme/components/Link/useLinkNavigate.test.ts diff --git a/.changeset/calm-navigation-await.md b/.changeset/calm-navigation-await.md new file mode 100644 index 000000000..feefe85c6 --- /dev/null +++ b/.changeset/calm-navigation-await.md @@ -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. diff --git a/packages/core/src/theme/components/Link/useLinkNavigate.test.ts b/packages/core/src/theme/components/Link/useLinkNavigate.test.ts new file mode 100644 index 000000000..af0664ab7 --- /dev/null +++ b/packages/core/src/theme/components/Link/useLinkNavigate.test.ts @@ -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', + ); + }); +}); diff --git a/packages/core/src/theme/components/Link/useLinkNavigate.ts b/packages/core/src/theme/components/Link/useLinkNavigate.ts index 374236929..d9dea1c8d 100644 --- a/packages/core/src/theme/components/Link/useLinkNavigate.ts +++ b/packages/core/src/theme/components/Link/useLinkNavigate.ts @@ -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('/'); } @@ -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)); } @@ -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 @@ -73,14 +91,15 @@ export function useLinkNavigate( }: { startTransition?: TransitionStartFunction } = { startTransition: reactStartTransition, }, -): (href: string) => Promise { +): (href: string, options?: { signal?: AbortSignal }) => Promise { 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); @@ -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 }); @@ -115,12 +140,115 @@ export function useLinkNavigate( } }; - if (isTransitionable) { - startTransition(preloadChunkThenNavigate); - } else { - preloadChunkThenNavigate(); - } + await preloadChunkThenNavigate(); }, [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 { + 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(null); + const queueRef = useRef>(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; + } + + let pending!: PendingNavigation; + const controller = new AbortController(); + const completion = new Promise((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], + ); +} diff --git a/packages/core/src/theme/index.ts b/packages/core/src/theme/index.ts index 145d98318..9dd0e1c9a 100644 --- a/packages/core/src/theme/index.ts +++ b/packages/core/src/theme/index.ts @@ -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,