chore(toolchain): upgrade Bun to 1.4.4 and migrate console to TanStack Router
This commit is contained in:
1 parent
b44ebc2ba1
commit
3b9376c75a
134 files changed
+893
-692
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in new issue
Block a user