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;
+}