diff --git a/packages/ui-next/index.ts b/packages/ui-next/index.ts
index 04db9e7fe..97f0bdfac 100644
--- a/packages/ui-next/index.ts
+++ b/packages/ui-next/index.ts
@@ -5,10 +5,9 @@ import c2k from 'koa2-connect/ts';
import { createServer, type Plugin } from 'vite';
import { serializer } from '@hydrooj/framework';
import type { Context } from 'hydrooj';
-import type { PageData } from './src/context/page-data';
const INJECT_MARKER = '';
-const buildInject = (str: string) => ``;
+const buildInject = (data: string) => ``;
function hydroPlugins(): Plugin {
const virtualModuleId = 'virtual:hydro-plugins';
@@ -75,15 +74,16 @@ export async function apply(ctx: Context) {
output: 'html',
asFallback: true,
priority: 100,
- async render(name, args, context) {
- const data: PageData = {
+ async render(_name, args, context) {
+ const serialized = JSON.stringify({
HYDRO_INJECTED: true,
name: context.handler.context._matchedRouteName,
args,
url: context.handler.context.req.url!,
- routeMap: ctx.server.routeMap,
- };
- const serialized = JSON.stringify(data, serializer(false, context.handler));
+ route_map: ctx.server.routeMap,
+ endpoint: ctx.setting.get('server.url') || undefined,
+ // hydro_domains: [],
+ }, serializer(false, context.handler));
const htmlToRender = html.replace(INJECT_MARKER, buildInject(serialized));
return await vite.transformIndexHtml(context.handler.context.req.url!, htmlToRender);
},
diff --git a/packages/ui-next/package.json b/packages/ui-next/package.json
index a1d9cc923..a485c277d 100644
--- a/packages/ui-next/package.json
+++ b/packages/ui-next/package.json
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "is-relative-url": "^4.1.0",
"path-to-regexp": "^8.4.2",
"react": "^19.2.5",
"react-dom": "^19.2.5"
diff --git a/packages/ui-next/src/components/link.tsx b/packages/ui-next/src/components/link.tsx
index e0ecce006..7f4b5ff4f 100644
--- a/packages/ui-next/src/components/link.tsx
+++ b/packages/ui-next/src/components/link.tsx
@@ -1,7 +1,6 @@
import React, { useCallback, useMemo } from 'react';
import { useNavigate } from '../context/router';
import { useUrl } from '../hooks/use-url';
-import { isSameOrigin } from '../utils/url';
export interface LinkProps extends Omit, 'href'> {
/** Pre-built href. Use this or `to`, not both. */
@@ -28,7 +27,6 @@ export const Link: React.FC> = ({ href, to, p
if (e.defaultPrevented || isModifiedEvent(e)) return;
if (target && target !== '_self') return;
if (download) 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;
diff --git a/packages/ui-next/src/context/page-data.tsx b/packages/ui-next/src/context/page-data.tsx
index 2f6cd4f58..54d9d07bd 100644
--- a/packages/ui-next/src/context/page-data.tsx
+++ b/packages/ui-next/src/context/page-data.tsx
@@ -3,12 +3,9 @@
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react';
export interface PageData {
- HYDRO_INJECTED?: boolean;
name: string;
args: Record;
url: string;
- routeMap: Record;
- [key: string]: any;
}
interface PageDataContextValue {
diff --git a/packages/ui-next/src/context/router.tsx b/packages/ui-next/src/context/router.tsx
index 7fc29899e..5253552f2 100644
--- a/packages/ui-next/src/context/router.tsx
+++ b/packages/ui-next/src/context/router.tsx
@@ -1,7 +1,7 @@
/* eslint-disable react-refresh/only-export-components */
import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
-import { isSameOrigin } from '../utils/url';
+import { endpointOrigins, endpoints, isInjected, routeMapStore } from '../globals';
import { useSetPageData } from './page-data';
interface InternalState {
@@ -43,8 +43,16 @@ export const RouterProvider: React.FC = ({ children })
const genRef = useRef(0);
const setData = useSetPageData();
+ const isSameOrigin = useCallback((url: string) => {
+ try {
+ return endpointOrigins.has(new URL(url, endpoints[0]).origin);
+ } catch {
+ return false;
+ }
+ }, []);
+
const fetchPage = useCallback(
- async (url: string) => {
+ async (url: string, init = false) => {
abortRef.current?.abort();
const gen = ++genRef.current;
const controller = new AbortController();
@@ -52,50 +60,66 @@ export const RouterProvider: React.FC = ({ children })
dispatch({ type: 'FETCH_START' });
- try {
- const res = await fetch(url, {
- signal: controller.signal,
- headers: {
- Accept: 'application/json',
- 'x-hydro-inject': 'uicontext,usercontext,pagename',
- },
- });
- if (res.redirected) {
- window.location.href = res.url;
- return false;
+ let lastError: Error | null = null;
+ for (const ep of endpoints) {
+ try {
+ const signal = endpoints.length > 1
+ ? AbortSignal.any([controller.signal, AbortSignal.timeout(10000)])
+ : controller.signal;
+ const reqUrl = new URL(url, ep).href;
+ const res = await fetch(reqUrl, {
+ signal,
+ headers: {
+ Accept: 'application/json',
+ 'x-hydro-inject': [
+ 'uicontext', 'usercontext', 'pagename',
+ ...(init ? ['routemap'] : []),
+ ].join(','),
+ },
+ });
+ 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', reqUrl, 'received:', body, 'pageName:', pageName);
+
+ if (gen !== genRef.current) return false;
+
+ if (init && body.routeMap && typeof body.routeMap === 'object') {
+ routeMapStore.set(body.routeMap);
+ }
+ setData((prev) => ({
+ ...prev,
+ args: body,
+ name: pageName,
+ url,
+ }));
+ 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;
+ }
+ lastError = e instanceof Error ? e : new Error(String(e));
+ console.warn('[Hydro] endpoint', ep, 'failed:', lastError.message);
+ if (controller.signal.aborted) {
+ // User-initiated abort propagated through AbortSignal.any
+ dispatch({ type: 'FETCH_ABORT' });
+ 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) => ({
- ...prev,
- args: body,
- name: pageName,
- url,
- }));
- 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;
}
+
+ console.error('[Hydro] all endpoints failed:', lastError);
+ if (gen !== genRef.current) return false;
+ dispatch({ type: 'FETCH_ERROR', error: lastError! });
+ window.location.href = url;
+ return false;
},
[setData],
);
@@ -111,10 +135,19 @@ export const RouterProvider: React.FC = ({ children })
return () => window.removeEventListener('popstate', handler);
}, [fetchPage]);
+ // If no server-side injection, fetch initial page data from the API
+ useEffect(() => {
+ if (!isInjected) {
+ console.log('[Hydro] no initial data injection found, fetching page data for current URL');
+ fetchPage(window.location.pathname + window.location.search, true);
+ }
+ }, [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;
@@ -122,7 +155,7 @@ export const RouterProvider: React.FC = ({ children })
}
const ok = await fetchPage(url);
if (ok) history.pushState({ url }, '', url);
- }, [fetchPage]);
+ }, [fetchPage, isSameOrigin]);
const navigateValue = useMemo(() => ({ navigate }), [navigate]);
diff --git a/packages/ui-next/src/globals.ts b/packages/ui-next/src/globals.ts
new file mode 100644
index 000000000..a986e1433
--- /dev/null
+++ b/packages/ui-next/src/globals.ts
@@ -0,0 +1,69 @@
+import isRelativeUrl from 'is-relative-url';
+import type { PageData } from './context/page-data';
+
+const injectionEl = document.getElementById('__HYDRO_INJECTION__');
+let injectionData: Record = {};
+if (injectionEl) {
+ try {
+ injectionData = JSON.parse(injectionEl.textContent!);
+ console.log('[Hydro] initial data:', injectionData);
+ } catch (e) {
+ console.error('[Hydro] Failed to parse injection data:', e);
+ }
+}
+
+export const isInjected: boolean = !!injectionData.HYDRO_INJECTED;
+export const hydroDomains: string[] = injectionData.hydro_domains ?? [];
+
+// routeMap as an external store for useSyncExternalStore, with HMR state preservation
+interface RouteMapStore {
+ _routeMap: Record;
+ _listeners: Set<() => void>;
+ getSnapshot: () => Record;
+ subscribe: (listener: () => void) => () => void;
+ set: (map: Record) => void;
+}
+
+function createRouteMapStore(initial: Record): RouteMapStore {
+ const store: RouteMapStore = {
+ _routeMap: initial,
+ _listeners: new Set(),
+ getSnapshot: () => store._routeMap,
+ subscribe: (listener: () => void) => {
+ store._listeners.add(listener);
+ return () => { store._listeners.delete(listener); };
+ },
+ set: (map: Record) => {
+ store._routeMap = { ...store._routeMap, ...map };
+ store._listeners.forEach((l) => l());
+ },
+ };
+ return store;
+}
+
+export const routeMapStore: RouteMapStore = import.meta.hot?.data?.routeMapStore
+ ?? createRouteMapStore(injectionData.route_map || {});
+if (import.meta.hot) import.meta.hot.data.routeMapStore = routeMapStore;
+
+export const endpoints: string[] = (() => {
+ if (hydroDomains.length) {
+ return hydroDomains
+ .map((d) => d.includes('://') ? d : `${window.location.protocol}//${d}`)
+ .map((d) => d.replace(/\/$/, ''));
+ }
+ if (typeof injectionData.endpoint === 'string') {
+ const ep = injectionData.endpoint;
+ if (isRelativeUrl(ep, { allowProtocolRelative: false })) {
+ return [new URL(ep, window.location.href).href.replace(/\/$/, '')];
+ }
+ return [ep.replace(/\/$/, '')];
+ }
+ return [window.location.origin];
+})();
+export const endpointOrigins = new Set(endpoints.map((ep) => new URL(ep).origin));
+
+export const initialPage: PageData = {
+ name: (injectionData.name as string) || '',
+ args: (injectionData.args as Record) || {},
+ url: (injectionData.url as string) || (window.location.pathname + window.location.search),
+};
diff --git a/packages/ui-next/src/hooks/use-route-map.ts b/packages/ui-next/src/hooks/use-route-map.ts
index f51c0743a..885db4fa5 100644
--- a/packages/ui-next/src/hooks/use-route-map.ts
+++ b/packages/ui-next/src/hooks/use-route-map.ts
@@ -1,6 +1,6 @@
-import { usePageData } from '../context/page-data';
+import { useSyncExternalStore } from 'react';
+import { routeMapStore } from '../globals';
-export function useRouteMap(): Record {
- const data = usePageData();
- return data.routeMap;
+export function useRouteMap() {
+ return useSyncExternalStore(routeMapStore.subscribe, routeMapStore.getSnapshot);
}
diff --git a/packages/ui-next/src/hooks/use-url.ts b/packages/ui-next/src/hooks/use-url.ts
index a505d6867..795bff72c 100644
--- a/packages/ui-next/src/hooks/use-url.ts
+++ b/packages/ui-next/src/hooks/use-url.ts
@@ -14,7 +14,7 @@ function buildUrl(pattern: string, params: Record = {}): string
export function useUrl() {
const routeMap = useRouteMap();
- const url = useCallback((name: string, params: Record = {}): string => {
+ return useCallback((name: string, params: Record = {}): string => {
const pattern = routeMap[name];
if (!pattern) {
console.warn(`[Hydro] Unknown route: ${name}`);
@@ -22,6 +22,4 @@ export function useUrl() {
}
return buildUrl(pattern, params);
}, [routeMap]);
-
- return url;
}
diff --git a/packages/ui-next/src/main.tsx b/packages/ui-next/src/main.tsx
index 93b5e6d83..540e19322 100644
--- a/packages/ui-next/src/main.tsx
+++ b/packages/ui-next/src/main.tsx
@@ -2,33 +2,18 @@ import { StrictMode } from 'react';
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 { PageDataProvider } from './context/page-data';
import { RouterProvider } from './context/router';
+import { initialPage } from './globals';
import { installPlugin } from './registry';
for (const plugin of plugins) {
installPlugin(plugin);
}
-const injectionEl = document.getElementById('__HYDRO_INJECTION__');
-let initialData: PageData = {
- name: '',
- args: {},
- url: window.location.pathname,
- routeMap: {},
-};
-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);
- }
-}
-
createRoot(document.getElementById('root')!).render(
-
+
diff --git a/packages/ui-next/src/utils/url.ts b/packages/ui-next/src/utils/url.ts
deleted file mode 100644
index b0e7a8de0..000000000
--- a/packages/ui-next/src/utils/url.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export function isSameOrigin(url: string): boolean {
- try {
- return new URL(url, window.location.origin).origin === window.location.origin;
- } catch {
- return false;
- }
-}