mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
ui-next: navigation
This commit is contained in:
6 files changed
+156
-16
No files matched your search
@@ -1,7 +1,11 @@
|
||||
// Components
|
||||
export { Link, type LinkProps } from './src/components/link';
|
||||
|
||||
// Context
|
||||
export { type PageData, usePageData } from './src/context/page-data';
|
||||
export { type NavigationState, type PageData, useNavigationState, usePageData } from './src/context/page-data';
|
||||
|
||||
// Hooks
|
||||
export { useNavigate } from './src/hooks/use-navigate';
|
||||
export { useUrl } from './src/hooks/use-url';
|
||||
|
||||
// Registry
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { usePageData } from './context/page-data';
|
||||
import { Component } from './registry';
|
||||
|
||||
const AppInner = Component('page:app', () => {
|
||||
const data = usePageData();
|
||||
|
||||
return <div>app, page:{data.name}</div>;
|
||||
});
|
||||
|
||||
export default AppInner;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Link } from './components/link';
|
||||
import { usePageData } from './context/page-data';
|
||||
import { Component } from './registry';
|
||||
|
||||
const AppInner = Component('page:app', () => {
|
||||
const data = usePageData();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>app, page:{data.name}</div>
|
||||
<div>
|
||||
<Link to="homepage">homepage</Link> <Link to="problem_main">problem_main</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default AppInner;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from '../hooks/use-navigate';
|
||||
import { useUrl } from '../hooks/use-url';
|
||||
|
||||
export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
||||
/** Pre-built href. Use this or `to`, not both. */
|
||||
href?: string;
|
||||
/** Route name to resolve via the route map. */
|
||||
to?: string;
|
||||
/** Params for route resolution when `to` is given. */
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
function isModifiedEvent(e: React.MouseEvent): boolean {
|
||||
return e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
||||
}
|
||||
|
||||
export const Link: React.FC<React.PropsWithChildren<LinkProps>> = ({ href, to, params, onClick, children, ...rest }) => {
|
||||
const buildUrl = useUrl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const resolvedHref = useMemo(() => (to ? buildUrl(to, params) : (href ?? '#')), [buildUrl, href, to, params]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(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;
|
||||
e.preventDefault();
|
||||
navigate(resolvedHref);
|
||||
},
|
||||
[onClick, resolvedHref, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<a href={resolvedHref} onClick={handleClick} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react';
|
||||
|
||||
export interface PageData {
|
||||
@@ -9,11 +11,17 @@ export interface PageData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
type PageDataSetter = (data: PageData) => void;
|
||||
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>>;
|
||||
}
|
||||
const PageDataContext = createContext<PageDataContextValue | null>(null);
|
||||
|
||||
@@ -24,14 +32,34 @@ interface PageDataProviderProps {
|
||||
|
||||
export function PageDataProvider({ initial, children }: PageDataProviderProps) {
|
||||
const [data, setData] = useState<PageData>(initial);
|
||||
const value = useMemo(() => ({ data, setData }), [data]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function usePageData(): PageData {
|
||||
function usePageDataContext(): PageDataContextValue {
|
||||
const ctx = useContext(PageDataContext);
|
||||
if (!ctx) throw new Error('usePageData must be used within PageDataProvider');
|
||||
return ctx.data;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user