From 979cda7c1a1b88661ec47d07b38e8d96995ea3ec Mon Sep 17 00:00:00 2001 From: Baoshuo Date: Sun, 26 Apr 2026 11:34:02 +0800 Subject: [PATCH] ui-next: refactor router --- eslint.config.mjs | 5 + packages/ui-next/api.ts | 6 +- packages/ui-next/src/components/link.tsx | 9 +- packages/ui-next/src/context/page-data.tsx | 30 +---- packages/ui-next/src/context/router.tsx | 143 +++++++++++++++++++++ packages/ui-next/src/hooks/use-navigate.ts | 59 --------- packages/ui-next/src/main.tsx | 6 +- packages/ui-next/src/utils/url.ts | 7 + 8 files changed, 173 insertions(+), 92 deletions(-) create mode 100644 packages/ui-next/src/context/router.tsx delete mode 100644 packages/ui-next/src/hooks/use-navigate.ts create mode 100644 packages/ui-next/src/utils/url.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 700c0e8a1..91bc1b7d2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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], }, diff --git a/packages/ui-next/api.ts b/packages/ui-next/api.ts index 4bcd159ff..71757047b 100644 --- a/packages/ui-next/api.ts +++ b/packages/ui-next/api.ts @@ -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 diff --git a/packages/ui-next/src/components/link.tsx b/packages/ui-next/src/components/link.tsx index 137a0f1dd..6389b77b2 100644 --- a/packages/ui-next/src/components/link.tsx +++ b/packages/ui-next/src/components/link.tsx @@ -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, 'href'> { /** Pre-built href. Use this or `to`, not both. */ @@ -25,8 +26,10 @@ export const Link: React.FC> = ({ href, to, p (e: React.MouseEvent) => { 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); }, diff --git a/packages/ui-next/src/context/page-data.tsx b/packages/ui-next/src/context/page-data.tsx index 24bc96253..2f6cd4f58 100644 --- a/packages/ui-next/src/context/page-data.tsx +++ b/packages/ui-next/src/context/page-data.tsx @@ -11,18 +11,11 @@ export interface PageData { [key: string]: any; } -type PageDataSetter = React.Dispatch>; - interface PageDataContextValue { data: PageData; - setData: PageDataSetter; - - loading: boolean; - setLoading: React.Dispatch>; - - error: Error | null; - setError: React.Dispatch>; + setData: React.Dispatch>; } + const PageDataContext = createContext(null); interface PageDataProviderProps { @@ -32,9 +25,7 @@ interface PageDataProviderProps { export function PageDataProvider({ initial, children }: PageDataProviderProps) { const [data, setData] = useState(initial); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const value = useMemo(() => ({ data, setData, loading, setLoading, error, setError }), [data, loading, error]); + const value = useMemo(() => ({ data, setData }), [data]); return {children}; } @@ -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> { + return usePageDataContext().setData; } diff --git a/packages/ui-next/src/context/router.tsx b/packages/ui-next/src/context/router.tsx new file mode 100644 index 000000000..1bfbb5cb3 --- /dev/null +++ b/packages/ui-next/src/context/router.tsx @@ -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; +} + +const RouterStateContext = createContext(null); +const RouterNavigateContext = createContext(null); + +export const RouterProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(routerReducer, { status: 'idle', error: null }); + const abortRef = useRef(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(() => ({ navigate }), [navigate]); + + return ( + + + {children} + + + ); +}; + +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 { + const ctx = useContext(RouterNavigateContext); + if (!ctx) throw new Error('useNavigate must be used within RouterProvider'); + return ctx.navigate; +} diff --git a/packages/ui-next/src/hooks/use-navigate.ts b/packages/ui-next/src/hooks/use-navigate.ts deleted file mode 100644 index c7d0b417e..000000000 --- a/packages/ui-next/src/hooks/use-navigate.ts +++ /dev/null @@ -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; -} diff --git a/packages/ui-next/src/main.tsx b/packages/ui-next/src/main.tsx index adf7cd2de..445cd04fa 100644 --- a/packages/ui-next/src/main.tsx +++ b/packages/ui-next/src/main.tsx @@ -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( - + + + , ); diff --git a/packages/ui-next/src/utils/url.ts b/packages/ui-next/src/utils/url.ts new file mode 100644 index 000000000..b0e7a8de0 --- /dev/null +++ b/packages/ui-next/src/utils/url.ts @@ -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; + } +}