mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
ui-next: refactor router
This commit is contained in:
8 files changed
+172
-91
No files matched your search
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}>
|
||||
<RouterProvider>
|
||||
<App />
|
||||
</RouterProvider>
|
||||
</PageDataProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user