From f71d6415714cdc3a8550c4ae532bb7f4206613a6 Mon Sep 17 00:00:00 2001 From: Baoshuo Date: Fri, 24 Apr 2026 22:56:22 +0800 Subject: [PATCH] ui-next: navigation --- packages/ui-next/api.ts | 6 ++- packages/ui-next/src/App.tsx | 10 ---- packages/ui-next/src/app.tsx | 18 +++++++ packages/ui-next/src/components/link.tsx | 41 +++++++++++++++ packages/ui-next/src/context/page-data.tsx | 38 ++++++++++++-- packages/ui-next/src/hooks/use-navigate.ts | 59 ++++++++++++++++++++++ 6 files changed, 156 insertions(+), 16 deletions(-) delete mode 100644 packages/ui-next/src/App.tsx create mode 100644 packages/ui-next/src/app.tsx create mode 100644 packages/ui-next/src/components/link.tsx create mode 100644 packages/ui-next/src/hooks/use-navigate.ts diff --git a/packages/ui-next/api.ts b/packages/ui-next/api.ts index 24e6bbb90..4bcd159ff 100644 --- a/packages/ui-next/api.ts +++ b/packages/ui-next/api.ts @@ -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 diff --git a/packages/ui-next/src/App.tsx b/packages/ui-next/src/App.tsx deleted file mode 100644 index 365d5a352..000000000 --- a/packages/ui-next/src/App.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { usePageData } from './context/page-data'; -import { Component } from './registry'; - -const AppInner = Component('page:app', () => { - const data = usePageData(); - - return
app, page:{data.name}
; -}); - -export default AppInner; diff --git a/packages/ui-next/src/app.tsx b/packages/ui-next/src/app.tsx new file mode 100644 index 000000000..c47277cc7 --- /dev/null +++ b/packages/ui-next/src/app.tsx @@ -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 ( + <> +
app, page:{data.name}
+
+ homepage problem_main +
+ + ); +}); + +export default AppInner; diff --git a/packages/ui-next/src/components/link.tsx b/packages/ui-next/src/components/link.tsx new file mode 100644 index 000000000..137a0f1dd --- /dev/null +++ b/packages/ui-next/src/components/link.tsx @@ -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, '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; +} + +function isModifiedEvent(e: React.MouseEvent): boolean { + return e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey; +} + +export const Link: React.FC> = ({ 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) => { + 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 ( + + {children} + + ); +}; diff --git a/packages/ui-next/src/context/page-data.tsx b/packages/ui-next/src/context/page-data.tsx index 34fc42435..24bc96253 100644 --- a/packages/ui-next/src/context/page-data.tsx +++ b/packages/ui-next/src/context/page-data.tsx @@ -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>; interface PageDataContextValue { data: PageData; setData: PageDataSetter; + + loading: boolean; + setLoading: React.Dispatch>; + + error: Error | null; + setError: React.Dispatch>; } const PageDataContext = createContext(null); @@ -24,14 +32,34 @@ interface PageDataProviderProps { export function PageDataProvider({ initial, children }: PageDataProviderProps) { const [data, setData] = useState(initial); - const value = useMemo(() => ({ data, setData }), [data]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const value = useMemo(() => ({ data, setData, loading, setLoading, error, setError }), [data, loading, error]); return {children}; } -// 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 }; } diff --git a/packages/ui-next/src/hooks/use-navigate.ts b/packages/ui-next/src/hooks/use-navigate.ts new file mode 100644 index 000000000..c7d0b417e --- /dev/null +++ b/packages/ui-next/src/hooks/use-navigate.ts @@ -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; +}