ui-next: refactor router

This commit is contained in:
Baoshuo committed 2026-05-10 01:12:47 +08:00
1 parent 4acb572597
commit 979cda7c1a
8 files changed
+173 -92

No files matched your search

+5
View File
@@ -1,6 +1,7 @@
/* eslint-disable max-len */
/* eslint-disable ts/naming-convention */
import path from 'node:path';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
import react from '@hydrooj/eslint-config';
@@ -194,12 +195,16 @@ const config = react({
},
}, {
files: ['packages/ui-next/src/**/*.{ts,tsx}'],
plugins: {
'react-hooks': reactHooks,
},
languageOptions: {
globals: {
...globals.browser,
},
},
rules: {
...reactHooks.configs.recommended.rules,
'style/indent': ['warn', 2],
'style/indent-binary-ops': ['warn', 2],
},
+2 -4
View File
@@ -2,10 +2,8 @@
export { Link, type LinkProps } from './src/components/link';
// Context
export { type NavigationState, type PageData, useNavigationState, usePageData } from './src/context/page-data';
// Hooks
export { useNavigate } from './src/hooks/use-navigate';
export { type PageData, usePageData } from './src/context/page-data';
export { type RouterState, useNavigate, useRouterState } from './src/context/router';
export { useUrl } from './src/hooks/use-url';
// Registry
+6 -3
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import { useNavigate } from '../hooks/use-navigate';
import { useNavigate } from '../context/router';
import { useUrl } from '../hooks/use-url';
import { isSameOrigin } from '../utils/url';
export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
/** Pre-built href. Use this or `to`, not both. */
@@ -25,8 +26,10 @@ export const Link: React.FC<React.PropsWithChildren<LinkProps>> = ({ href, to, p
(e: React.MouseEvent<HTMLAnchorElement>) => {
onClick?.(e);
if (e.defaultPrevented || isModifiedEvent(e)) return;
// Let the browser handle external links and javascript/mailto URIs
if (!resolvedHref.startsWith('/') && !resolvedHref.startsWith(window.location.origin)) return;
if (!isSameOrigin(resolvedHref)) return;
if (resolvedHref.startsWith('#')) return;
const resolved = new URL(resolvedHref, window.location.href);
if (resolved.pathname === window.location.pathname && resolved.hash) return;
e.preventDefault();
navigate(resolvedHref);
},
+5 -25
View File
@@ -11,18 +11,11 @@ export interface PageData {
[key: string]: any;
}
type PageDataSetter = React.Dispatch<React.SetStateAction<PageData>>;
interface PageDataContextValue {
data: PageData;
setData: PageDataSetter;
loading: boolean;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
error: Error | null;
setError: React.Dispatch<React.SetStateAction<Error | null>>;
setData: React.Dispatch<React.SetStateAction<PageData>>;
}
const PageDataContext = createContext<PageDataContextValue | null>(null);
interface PageDataProviderProps {
@@ -32,9 +25,7 @@ interface PageDataProviderProps {
export function PageDataProvider({ initial, children }: PageDataProviderProps) {
const [data, setData] = useState<PageData>(initial);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const value = useMemo(() => ({ data, setData, loading, setLoading, error, setError }), [data, loading, error]);
const value = useMemo(() => ({ data, setData }), [data]);
return <PageDataContext.Provider value={value}>{children}</PageDataContext.Provider>;
}
@@ -49,17 +40,6 @@ export function usePageData(): PageData {
return usePageDataContext().data;
}
export interface NavigationState {
loading: PageDataContextValue['loading'];
error: PageDataContextValue['error'];
}
export function useNavigationState(): NavigationState {
const { loading, error } = usePageDataContext();
return { loading, error };
}
export function useNavigationControls() {
const { setData, setLoading, setError } = usePageDataContext();
return { setData, setLoading, setError };
export function useSetPageData(): React.Dispatch<React.SetStateAction<PageData>> {
return usePageDataContext().setData;
}
+143
View File
@@ -0,0 +1,143 @@
/* eslint-disable react-refresh/only-export-components */
import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
import { isSameOrigin } from '../utils/url';
import { type PageData, useSetPageData } from './page-data';
interface InternalState {
status: 'idle' | 'loading' | 'error';
error: Error | null;
}
type RouterAction =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS' }
| { type: 'FETCH_ERROR', error: Error }
| { type: 'FETCH_ABORT' };
function routerReducer(state: InternalState, action: RouterAction): InternalState {
switch (action.type) {
case 'FETCH_START': return { status: 'loading', error: null };
case 'FETCH_SUCCESS': return { status: 'idle', error: null };
case 'FETCH_ERROR': return { status: 'error', error: action.error };
case 'FETCH_ABORT': return state;
default: return state;
}
}
export interface RouterState {
loading: boolean;
error: Error | null;
}
interface RouterNavigateContextValue {
navigate: (url: string) => Promise<void>;
}
const RouterStateContext = createContext<RouterState | null>(null);
const RouterNavigateContext = createContext<RouterNavigateContextValue | null>(null);
export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children }) => {
const [state, dispatch] = useReducer(routerReducer, { status: 'idle', error: null });
const abortRef = useRef<AbortController | null>(null);
const genRef = useRef(0);
const setData = useSetPageData();
const fetchPage = useCallback(
async (url: string) => {
abortRef.current?.abort();
const gen = ++genRef.current;
const controller = new AbortController();
abortRef.current = controller;
dispatch({ type: 'FETCH_START' });
try {
const res = await fetch(url, {
signal: controller.signal,
headers: {
Accept: 'application/json',
'x-hydro-inject': 'uicontext,usercontext',
},
});
if (res.redirected) {
window.location.href = res.url;
return false;
}
if (!res.ok) throw new Error(`Navigation failed: ${res.status} ${res.statusText}`);
const body = await res.json();
const pageName = res.headers.get('x-hydro-page') || '';
console.log('[Hydro] data from', url, 'received:', body, 'pageName:', pageName);
// If a newer fetch has started, ignore this result
if (gen !== genRef.current) return false;
setData((prev) => (body.HYDRO_INJECTED ? body : { ...prev, ...body }));
dispatch({ type: 'FETCH_SUCCESS' });
return true;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
dispatch({ type: 'FETCH_ABORT' });
console.log('[Hydro] navigation to', url, 'aborted');
return false;
}
const err = e instanceof Error ? e : new Error(String(e));
console.error('[Hydro] navigation error:', err);
if (gen !== genRef.current) return false;
dispatch({ type: 'FETCH_ERROR', error: err });
window.location.href = url;
return false;
}
},
[setData],
);
useEffect(() => {
const handler = (e: PopStateEvent) => {
const url: string =
(e.state as { url?: string } | null)?.url
?? window.location.pathname + window.location.search;
fetchPage(url);
};
window.addEventListener('popstate', handler);
return () => window.removeEventListener('popstate', handler);
}, [fetchPage]);
const stateValue = useMemo(
() => ({ loading: state.status === 'loading', error: state.error }),
[state.status, state.error],
);
const navigate = useCallback(async (url: string) => {
if (!isSameOrigin(url)) {
window.location.href = url;
return;
}
const ok = await fetchPage(url);
if (ok) history.pushState({ url }, '', url);
}, [fetchPage]);
const navigateValue = useMemo<RouterNavigateContextValue>(() => ({ navigate }), [navigate]);
return (
<RouterNavigateContext.Provider value={navigateValue}>
<RouterStateContext.Provider value={stateValue}>
{children}
</RouterStateContext.Provider>
</RouterNavigateContext.Provider>
);
};
export function useRouterState(): RouterState {
const ctx = useContext(RouterStateContext);
if (!ctx) throw new Error('useRouterState must be used within RouterProvider');
return ctx;
}
export function useNavigate(): (url: string) => Promise<void> {
const ctx = useContext(RouterNavigateContext);
if (!ctx) throw new Error('useNavigate must be used within RouterProvider');
return ctx.navigate;
}
@@ -1,59 +0,0 @@
import { useCallback } from 'react';
import { type PageData, useNavigationControls } from '../context/page-data';
function buildReqUrl(url: string): string {
try {
const u = new URL(url, window.location.origin);
u.searchParams.set('_ui_next', '1');
return u.pathname + u.search + u.hash;
} catch {
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}_ui_next=1`;
}
}
function isSameOrigin(url: string): boolean {
try {
return new URL(url, window.location.origin).origin === window.location.origin;
} catch {
return false;
}
}
export function useNavigate() {
const { setData, setLoading, setError } = useNavigationControls();
const navigate = useCallback(async (url: string) => {
if (!isSameOrigin(url)) {
window.location.href = url;
return;
}
setLoading(true);
setError(null);
try {
const fetchUrl = buildReqUrl(url);
const res = await fetch(fetchUrl, { headers: { Accept: 'application/json', 'x-hydro-inject': 'uicontext,usercontext' } });
if (res.redirected) {
window.location.href = res.url;
return;
}
if (!res.ok) {
throw new Error(`Navigation failed: ${res.status} ${res.statusText}`);
}
const data: PageData = await res.json();
console.log('[Hydro] data received:', data);
setData((prev) => (data.HYDRO_INJECTED ? data : { ...prev, ...data }));
history.pushState(null, '', url);
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
setError(err);
console.error('[Hydro] navigation error:', err);
// Fall back to hard navigation on unexpected errors
window.location.href = url;
} finally {
setLoading(false);
}
}, [setData, setLoading, setError]);
return navigate;
}
+5 -1
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import plugins from 'virtual:hydro-plugins';
import App from './app';
import { type PageData, PageDataProvider } from './context/page-data';
import { RouterProvider } from './context/router';
import { createRegistryContext } from './registry';
const ctx = createRegistryContext();
@@ -20,6 +21,7 @@ let initialData: PageData = {
if (injectionEl) {
try {
initialData = JSON.parse(injectionEl.textContent!);
console.log('[Hydro] initial data:', initialData);
} catch (e) {
console.error('[Hydro] Failed to parse injection data:', e);
}
@@ -28,7 +30,9 @@ if (injectionEl) {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<PageDataProvider initial={initialData}>
<App />
<RouterProvider>
<App />
</RouterProvider>
</PageDataProvider>
</StrictMode>,
);
+7
View File
@@ -0,0 +1,7 @@
export function isSameOrigin(url: string): boolean {
try {
return new URL(url, window.location.origin).origin === window.location.origin;
} catch {
return false;
}
}