mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
ui-next: refactor router
This commit is contained in:
8 files changed
+173
-92
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable max-len */
|
/* eslint-disable max-len */
|
||||||
/* eslint-disable ts/naming-convention */
|
/* eslint-disable ts/naming-convention */
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
import globals from 'globals';
|
import globals from 'globals';
|
||||||
import react from '@hydrooj/eslint-config';
|
import react from '@hydrooj/eslint-config';
|
||||||
|
|
||||||
@@ -194,12 +195,16 @@ const config = react({
|
|||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
files: ['packages/ui-next/src/**/*.{ts,tsx}'],
|
files: ['packages/ui-next/src/**/*.{ts,tsx}'],
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
'style/indent': ['warn', 2],
|
'style/indent': ['warn', 2],
|
||||||
'style/indent-binary-ops': ['warn', 2],
|
'style/indent-binary-ops': ['warn', 2],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
export { Link, type LinkProps } from './src/components/link';
|
export { Link, type LinkProps } from './src/components/link';
|
||||||
|
|
||||||
// Context
|
// Context
|
||||||
export { type NavigationState, type PageData, useNavigationState, usePageData } from './src/context/page-data';
|
export { type PageData, usePageData } from './src/context/page-data';
|
||||||
|
export { type RouterState, useNavigate, useRouterState } from './src/context/router';
|
||||||
// Hooks
|
|
||||||
export { useNavigate } from './src/hooks/use-navigate';
|
|
||||||
export { useUrl } from './src/hooks/use-url';
|
export { useUrl } from './src/hooks/use-url';
|
||||||
|
|
||||||
// Registry
|
// Registry
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from '../hooks/use-navigate';
|
import { useNavigate } from '../context/router';
|
||||||
import { useUrl } from '../hooks/use-url';
|
import { useUrl } from '../hooks/use-url';
|
||||||
|
import { isSameOrigin } from '../utils/url';
|
||||||
|
|
||||||
export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
||||||
/** Pre-built href. Use this or `to`, not both. */
|
/** 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>) => {
|
(e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
onClick?.(e);
|
onClick?.(e);
|
||||||
if (e.defaultPrevented || isModifiedEvent(e)) return;
|
if (e.defaultPrevented || isModifiedEvent(e)) return;
|
||||||
// Let the browser handle external links and javascript/mailto URIs
|
if (!isSameOrigin(resolvedHref)) return;
|
||||||
if (!resolvedHref.startsWith('/') && !resolvedHref.startsWith(window.location.origin)) return;
|
if (resolvedHref.startsWith('#')) return;
|
||||||
|
const resolved = new URL(resolvedHref, window.location.href);
|
||||||
|
if (resolved.pathname === window.location.pathname && resolved.hash) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(resolvedHref);
|
navigate(resolvedHref);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,18 +11,11 @@ export interface PageData {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PageDataSetter = React.Dispatch<React.SetStateAction<PageData>>;
|
|
||||||
|
|
||||||
interface PageDataContextValue {
|
interface PageDataContextValue {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
setData: PageDataSetter;
|
setData: React.Dispatch<React.SetStateAction<PageData>>;
|
||||||
|
|
||||||
loading: boolean;
|
|
||||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
|
|
||||||
error: Error | null;
|
|
||||||
setError: React.Dispatch<React.SetStateAction<Error | null>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageDataContext = createContext<PageDataContextValue | null>(null);
|
const PageDataContext = createContext<PageDataContextValue | null>(null);
|
||||||
|
|
||||||
interface PageDataProviderProps {
|
interface PageDataProviderProps {
|
||||||
@@ -32,9 +25,7 @@ interface PageDataProviderProps {
|
|||||||
|
|
||||||
export function PageDataProvider({ initial, children }: PageDataProviderProps) {
|
export function PageDataProvider({ initial, children }: PageDataProviderProps) {
|
||||||
const [data, setData] = useState<PageData>(initial);
|
const [data, setData] = useState<PageData>(initial);
|
||||||
const [loading, setLoading] = useState(false);
|
const value = useMemo(() => ({ data, setData }), [data]);
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const value = useMemo(() => ({ data, setData, loading, setLoading, error, setError }), [data, loading, error]);
|
|
||||||
|
|
||||||
return <PageDataContext.Provider value={value}>{children}</PageDataContext.Provider>;
|
return <PageDataContext.Provider value={value}>{children}</PageDataContext.Provider>;
|
||||||
}
|
}
|
||||||
@@ -49,17 +40,6 @@ export function usePageData(): PageData {
|
|||||||
return usePageDataContext().data;
|
return usePageDataContext().data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavigationState {
|
export function useSetPageData(): React.Dispatch<React.SetStateAction<PageData>> {
|
||||||
loading: PageDataContextValue['loading'];
|
return usePageDataContext().setData;
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
@@ -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 plugins from 'virtual:hydro-plugins';
|
||||||
import App from './app';
|
import App from './app';
|
||||||
import { type PageData, PageDataProvider } from './context/page-data';
|
import { type PageData, PageDataProvider } from './context/page-data';
|
||||||
|
import { RouterProvider } from './context/router';
|
||||||
import { createRegistryContext } from './registry';
|
import { createRegistryContext } from './registry';
|
||||||
|
|
||||||
const ctx = createRegistryContext();
|
const ctx = createRegistryContext();
|
||||||
@@ -20,6 +21,7 @@ let initialData: PageData = {
|
|||||||
if (injectionEl) {
|
if (injectionEl) {
|
||||||
try {
|
try {
|
||||||
initialData = JSON.parse(injectionEl.textContent!);
|
initialData = JSON.parse(injectionEl.textContent!);
|
||||||
|
console.log('[Hydro] initial data:', initialData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Hydro] Failed to parse injection data:', e);
|
console.error('[Hydro] Failed to parse injection data:', e);
|
||||||
}
|
}
|
||||||
@@ -28,7 +30,9 @@ if (injectionEl) {
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<PageDataProvider initial={initialData}>
|
<PageDataProvider initial={initialData}>
|
||||||
<App />
|
<RouterProvider>
|
||||||
|
<App />
|
||||||
|
</RouterProvider>
|
||||||
</PageDataProvider>
|
</PageDataProvider>
|
||||||
</StrictMode>,
|
</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