import { type AnyRouter, Outlet, useRouter, useRouterState, useParams as useTanstackParams, } from "@tanstack/react-router"; import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from "react"; import { createContext, forwardRef, useCallback, useContext } from "react"; // Keep URL search values as strings while the console's existing links move to TanStack Router. export function parseUrlSearch(value: string): Record { const result: Record = Object.create(null); for (const [key, part] of new URLSearchParams(value)) { const previous = result[key]; result[key] = previous === undefined ? part : [...(Array.isArray(previous) ? previous : [previous]), part]; } return result; } export function stringifyUrlSearch(search: Record): string { const params = new URLSearchParams(); for (const [key, value] of Object.entries(search)) { if (value == null) continue; for (const part of Array.isArray(value) ? value : [value]) params.append(key, String(part)); } const value = params.toString(); return value ? `?${value}` : ""; } export type Location = { pathname: string; search: string; hash: string; state: unknown; key: string; }; const TestLocation = createContext(null); let navigationSequence = 0; declare module "@tanstack/react-router" { interface HistoryState { __umtNavigationKey?: string; } } export function useLocation(): Location { const testLocation = useContext(TestLocation); // The test provider is fixed for a render tree; production always uses RouterProvider. if (testLocation) return testLocation; // biome-ignore lint/correctness/useHookAtTopLevel: provider branch is stable for the component lifetime const location = useRouterState({ select: (state) => state.location }); return { pathname: location.pathname, search: location.searchStr, hash: location.hash ? (location.hash.startsWith("#") ? location.hash : `#${location.hash}`) : "", state: location.state, key: location.state.__TSR_key ?? location.href, }; } type To = string | { pathname?: string; search?: string; hash?: string }; type NavigationOptions = { replace?: boolean; state?: unknown; preventScrollReset?: boolean }; export function navigateTo(router: AnyRouter, target: URL, options: NavigationOptions = {}) { return router.navigate({ to: target.pathname, search: parseUrlSearch(target.search), hash: target.hash.slice(1), replace: options.replace, resetScroll: !options.preventScrollReset, // 显式导航即使地址和业务 state 相同,也要更新 key,让设置搜索能再次定位同一字段。 state: { ...((options.state ?? {}) as Record), __umtNavigationKey: `${Date.now()}:${++navigationSequence}`, }, }); } export function resolveTo(to: To, current: Pick): string { if (typeof to === "string") { if (to.startsWith("?") || to.startsWith("#")) return `${current.pathname}${to.startsWith("#") ? current.search : ""}${to}`; return to; } return `${to.pathname ?? current.pathname}${to.search ?? ""}${to.hash ?? ""}`; } export function useNavigate() { const router = useRouter({ warn: false }); const { pathname, search, hash } = useLocation(); return useCallback( (to: To | number, options: NavigationOptions = {}) => { if (typeof to === "number") { router?.history.go(to); return; } const target = new URL(resolveTo(to, { pathname, search, hash }), window.location.origin); if (target.origin !== window.location.origin) { window.location.assign(target.href); return; } if (!router) return; void navigateTo(router, target, options); }, [router, pathname, search, hash], ); } type SearchInput = URLSearchParams | Record; export function useSearchParams(): [ URLSearchParams, (next: SearchInput | ((current: URLSearchParams) => SearchInput), options?: NavigationOptions) => void, ] { const location = useLocation(); const navigate = useNavigate(); const set = useCallback( (next: SearchInput | ((current: URLSearchParams) => SearchInput), options?: NavigationOptions) => { const value = typeof next === "function" ? next(new URLSearchParams(location.search)) : next; const params = value instanceof URLSearchParams ? value : new URLSearchParams(value); navigate({ search: params.toString() ? `?${params}` : "" }, options); }, [location.search, navigate], ); return [new URLSearchParams(location.search), set]; } export function useParams = Record>(): T { const testLocation = useContext(TestLocation); if (testLocation) return {} as T; // biome-ignore lint/correctness/useHookAtTopLevel: provider branch is stable for the component lifetime return useTanstackParams({ strict: false } as never) as T; } export type LinkProps = Omit, "href"> & { to: To; replace?: boolean; state?: unknown; preventScrollReset?: boolean; }; export const Link = forwardRef(function Link( { to, replace, state, preventScrollReset, onClick, target, children, ...props }, ref, ) { const location = useLocation(); const navigate = useNavigate(); const href = resolveTo(to, location); const click = (event: MouseEvent) => { onClick?.(event); if ( event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || target === "_blank" || props.download ) return; const url = new URL(href, window.location.origin); if (url.origin !== window.location.origin) return; event.preventDefault(); navigate(href, { replace, state, preventScrollReset }); }; return ( {children} ); }); export function redirect(to: string): Response { return new Response(null, { status: 302, headers: { Location: to } }); } export { Outlet }; // Existing component tests mount only a memory navigation context. export function MemoryRouter({ children, initialEntries }: { children?: ReactNode; initialEntries?: string[] }) { const url = new URL(initialEntries?.[0] ?? "/", "http://localhost"); const location: Location = { pathname: url.pathname, search: url.search, hash: url.hash, state: null, key: "test" }; return {children}; }