chore(toolchain): upgrade Bun to 1.4.4 and migrate console to TanStack Router

This commit is contained in:
root committed 2026-11-06 00:02:36 +08:00
1 parent b44ebc2ba1
commit 3b9376c75a
134 files changed
+893 -692

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
import { createMemoryHistory, createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { navigateTo, parseUrlSearch, resolveTo, stringifyUrlSearch } from "./router-compat.tsx";
const current = { pathname: "/console/files", search: "?view=trash", hash: "#old" };
afterEach(() => vi.unstubAllGlobals());
describe("路由目标兼容", () => {
it("对象目标不继承未指定的查询和锚点", () => {
expect(resolveTo({ pathname: "/console/settings", hash: "#account/totp" }, current)).toBe(
"/console/settings#account/totp",
);
expect(resolveTo({ search: "?view=all" }, current)).toBe("/console/files?view=all");
});
it("仅有锚点的字符串目标保留当前查询", () => {
expect(resolveTo("#details", current)).toBe("/console/files?view=trash#details");
});
it("重复定位同一设置字段生成新的导航 key,后退恢复原来的业务 state", async () => {
vi.stubGlobal("window", { origin: "http://localhost" });
vi.stubGlobal("crypto", undefined);
const root = createRootRoute();
const settings = createRoute({ getParentRoute: () => root, path: "/console/settings" });
const history = createMemoryHistory({ initialEntries: ["/console/settings"] });
const router = createRouter({
routeTree: root.addChildren([settings]),
history,
isServer: false,
parseSearch: parseUrlSearch,
stringifySearch: stringifyUrlSearch,
});
const unsubscribe = history.subscribe(() => void router.load());
try {
await router.load();
const target = new URL("/console/settings?tag=one&tag=two#account/totp", window.origin);
const state = { field: "account.username", sheetFrom: "/console/settings" };
await navigateTo(router, target, { state, preventScrollReset: true });
const firstKey = router.state.location.state.__TSR_key;
const firstIndex = history.location.state.__TSR_index;
await navigateTo(router, target, { state, preventScrollReset: true });
expect(router.state.location.state.__TSR_key).not.toBe(firstKey);
expect(history.location.state.__TSR_index).toBe(firstIndex + 1);
expect(router.state.location.state).toMatchObject(state);
expect(router.state.location.search).toEqual({ tag: ["one", "two"] });
expect(router.state.location.hash).toBe("account/totp");
history.back();
await router.load();
expect(router.state.location.state.__TSR_key).toBe(firstKey);
expect(router.state.location.state).toMatchObject(state);
} finally {
unsubscribe();
}
});
});
+185
View File
@@ -0,0 +1,185 @@
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<string, string | string[]> {
const result: Record<string, string | string[]> = 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, unknown>): 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<Location | null>(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<string, unknown>),
__umtNavigationKey: `${Date.now()}:${++navigationSequence}`,
},
});
}
export function resolveTo(to: To, current: Pick<Location, "pathname" | "search" | "hash">): 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<string, string>;
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<T extends Record<string, string | undefined> = Record<string, string | undefined>>(): 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<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
to: To;
replace?: boolean;
state?: unknown;
preventScrollReset?: boolean;
};
export const Link = forwardRef<HTMLAnchorElement, LinkProps>(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<HTMLAnchorElement>) => {
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 (
<a {...props} ref={ref} href={href} target={target} onClick={click}>
{children}
</a>
);
});
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 <TestLocation value={location}>{children}</TestLocation>;
}