ui-next: layout refactor & dynamic template (#1182)

This commit is contained in:
Baoshuo authored and GitHub committed 2026-06-20 17:03:54 +08:00
1 parent e39a5fa87f
commit 5d5a168572
15 files changed
+113 -54

No files matched your search

+1
View File
@@ -84,6 +84,7 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => {
const inject = request.headers['x-hydro-inject'].toString().toLowerCase().split(',').map((i) => i.trim()); const inject = request.headers['x-hydro-inject'].toString().toLowerCase().split(',').map((i) => i.trim());
if (inject.includes('pagename')) { if (inject.includes('pagename')) {
ctx.set('x-hydro-page', ctx._matchedRouteName || ''); ctx.set('x-hydro-page', ctx._matchedRouteName || '');
ctx.set('x-hydro-template', response.template || '');
} }
if (response.body !== null && typeof response.body === 'object') { if (response.body !== null && typeof response.body === 'object') {
if (inject.includes('uicontext')) response.body.UiContext = UiContext; if (inject.includes('uicontext')) response.body.UiContext = UiContext;
+2
View File
@@ -191,6 +191,7 @@ export async function apply(ctx: Context) {
const serialized = JSON.stringify({ const serialized = JSON.stringify({
HYDRO_INJECTED: true, HYDRO_INJECTED: true,
name: context.handler.context._matchedRouteName, name: context.handler.context._matchedRouteName,
template: context.handler.response.template || '',
args: { args: {
UserContext: context.UserContext, UserContext: context.UserContext,
UiContext: context.handler.UiContext, UiContext: context.handler.UiContext,
@@ -224,6 +225,7 @@ export async function apply(ctx: Context) {
const serialized = JSON.stringify({ const serialized = JSON.stringify({
HYDRO_INJECTED: true, HYDRO_INJECTED: true,
name: context.handler.context._matchedRouteName, name: context.handler.context._matchedRouteName,
template: context.handler.response.template || '',
args: { args: {
UserContext: context.UserContext, UserContext: context.UserContext,
UiContext: context.handler.UiContext, UiContext: context.handler.UiContext,
+36 -7
View File
@@ -1,12 +1,36 @@
import { Suspense, useMemo, useSyncExternalStore } from 'react'; import { Suspense, useMemo, useSyncExternalStore } from 'react';
import DefaultLayout from './components/layout';
import { usePageData } from './context/page-data'; import { usePageData } from './context/page-data';
import { defineSlot, SlotName } from './registry'; import { defineSlot } from './registry';
import { SlotErrorBoundary } from './registry/error-boundary'; import { SlotErrorBoundary } from './registry/error-boundary';
import { store } from './registry/store'; import { store } from './registry/store';
const App = defineSlot('app:root', () => { const App = defineSlot('app:root', () => {
const { name } = usePageData(); const { name, template, args } = usePageData();
const slotName: SlotName = `page:${name}`;
const isError = !!(args as Record<string, unknown>).error;
const [slotName, entry] = useMemo(() => {
if (isError) {
return ['page:error', store.getDefault('page:error')] as const;
}
const templateName = typeof template === 'string' ? template.replace(/\.html$/, '') : null;
if (templateName) {
const templateSlot = `page:${templateName}` as `page:${string}`;
const templateEntry = store.getDefault(templateSlot);
if (templateEntry) {
if (import.meta.env.DEV) {
console.log(`[ui-next] using template "${templateName}" for page "${name}"`);
}
return [templateSlot, templateEntry] as const;
}
}
const slot = `page:${name}` as `page:${string}`;
if (import.meta.env.DEV) {
console.log(`[ui-next] using page slot "${slot}"`);
}
return [slot, store.getDefault(slot)] as const;
}, [name, template, isError]);
const [subscribe, getSnapshot] = useMemo(() => [ const [subscribe, getSnapshot] = useMemo(() => [
(cb: () => void) => store.subscribe(slotName, cb), (cb: () => void) => store.subscribe(slotName, cb),
@@ -15,9 +39,7 @@ const App = defineSlot('app:root', () => {
useSyncExternalStore(subscribe, getSnapshot); useSyncExternalStore(subscribe, getSnapshot);
const Comp = store.getDefault(slotName); if (!entry) {
if (!Comp) {
return ( return (
<div> <div>
Page not found: <code>{name}</code> Page not found: <code>{name}</code>
@@ -25,10 +47,17 @@ const App = defineSlot('app:root', () => {
); );
} }
const Layout = store.getDefault(`layout:${entry.layout}`) ?? DefaultLayout;
const { Page } = entry;
return ( return (
<SlotErrorBoundary slotName={slotName} label="renderer"> <SlotErrorBoundary slotName={slotName} label="renderer">
<Suspense fallback={null}>
<Layout>
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<Comp /> <Page />
</Suspense>
</Layout>
</Suspense> </Suspense>
</SlotErrorBoundary> </SlotErrorBoundary>
); );
+4 -1
View File
@@ -1,5 +1,8 @@
import { defineSlot } from '../registry'; import { defineSlot } from '../registry';
const Layout = defineSlot('app:layout', ({ children }: React.PropsWithChildren) => <>{children}</>); const Layout = defineSlot('layout:default', ({ children }: React.PropsWithChildren) => {
console.log('[ui-next] using default layout');
return <>{children}</>;
});
export default Layout; export default Layout;
@@ -4,6 +4,7 @@ import { createContext, type ReactNode, useContext, useMemo, useState } from 're
export interface PageData { export interface PageData {
name: string; name: string;
template: string;
args: { args: {
UserContext: Record<string, any>; UserContext: Record<string, any>;
UiContext: Record<string, any>; UiContext: Record<string, any>;
+2
View File
@@ -84,6 +84,7 @@ export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children })
if (!res.ok) throw new Error(`Navigation failed: ${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`Navigation failed: ${res.status} ${res.statusText}`);
const body = await res.json(); const body = await res.json();
const pageName = res.headers.get('x-hydro-page') || ''; const pageName = res.headers.get('x-hydro-page') || '';
const template = res.headers.get('x-hydro-template') || '';
console.log('[Hydro] data from', reqUrl, 'received:', body, 'pageName:', pageName); console.log('[Hydro] data from', reqUrl, 'received:', body, 'pageName:', pageName);
if (gen !== genRef.current) return false; if (gen !== genRef.current) return false;
@@ -95,6 +96,7 @@ export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children })
...prev, ...prev,
args: body, args: body,
name: pageName, name: pageName,
template,
url, url,
})); }));
dispatch({ type: 'FETCH_SUCCESS' }); dispatch({ type: 'FETCH_SUCCESS' });
+1
View File
@@ -64,6 +64,7 @@ export const endpointOrigins = new Set(endpoints.map((ep) => new URL(ep).origin)
export const initialPage: PageData = { export const initialPage: PageData = {
name: (injectionData.name as string) || '', name: (injectionData.name as string) || '',
template: (injectionData.template as string) || '',
args: (injectionData.args as any) || {}, args: (injectionData.args as any) || {},
url: (injectionData.url as string) || (window.location.pathname + window.location.search), url: (injectionData.url as string) || (window.location.pathname + window.location.search),
}; };
-9
View File
@@ -8,12 +8,3 @@ export default function Homepage() {
</div> </div>
); );
} }
export function Layout({ children }: React.PropsWithChildren) {
return (
<div>
{children}
<div>layout test</div>
</div>
);
}
+6 -1
View File
@@ -1,7 +1,12 @@
export { SlotErrorBoundary } from './error-boundary'; export { SlotErrorBoundary } from './error-boundary';
export { after, before, intercept, patch, replace, wrap } from './interceptors'; export { after, before, intercept, patch, replace, wrap } from './interceptors';
export { registerLayout } from './layout';
export type { LayoutComponent } from './layout';
export { registerPage } from './page'; export { registerPage } from './page';
export { installPlugin } from './plugin'; export { installPlugin } from './plugin';
export type { PluginAPI, PluginDefinition } from './plugin'; export type { PluginAPI, PluginDefinition } from './plugin';
export { defineSlot } from './slot'; export { defineSlot } from './slot';
export type { Interceptor, InterceptorEntry, InterceptorOptions, SlotName } from './types'; export type {
AppSlotName, ComponentSlotName, Interceptor, InterceptorEntry, InterceptorOptions,
LayoutSlotName, PageEntry, PageSlotName, RegisterPageOptions, SlotName, SlotValue,
} from './types';
+7
View File
@@ -0,0 +1,7 @@
import { store } from './store';
export type LayoutComponent = React.ComponentType<React.PropsWithChildren>;
export function registerLayout(name: string, Comp: LayoutComponent) {
store.setDefault(`layout:${name}` as `layout:${string}`, Comp);
}
+10 -25
View File
@@ -1,28 +1,13 @@
import React, { lazy } from 'react'; import { lazy } from 'react';
import Layout from '../components/layout';
import { store } from './store'; import { store } from './store';
import type { PageEntry, PageLoader, PageSlotName, RegisterPageOptions } from './types';
interface PageModule<P = any> { export function registerPage<P = any>(
default: React.ComponentType<P>; name: string,
Layout?: React.ComponentType<React.PropsWithChildren>; loader: PageLoader<P>,
} options: RegisterPageOptions = {},
) {
type PageLoader<P = any> = () => Promise<PageModule<P>>; const Page = lazy(loader);
const entry: PageEntry<P> = { Page, layout: options.layout ?? 'default' };
export function registerPage<P = any>(name: string, loader: PageLoader<P>) { store.setDefault(`page:${name}` as PageSlotName, entry);
store.setDefault(`page:${name}`, lazy(() =>
loader().then((mod) => {
const PageComp = mod.default;
const LayoutComp = mod.Layout || Layout;
return {
default(props: React.PropsWithChildren<P>) {
return (
<LayoutComp>
<PageComp {...props} />
</LayoutComp>
);
},
};
}),
));
} }
+6 -1
View File
@@ -1,4 +1,5 @@
import { after, before, intercept, patch, replace, wrap } from './interceptors'; import { after, before, intercept, patch, replace, wrap } from './interceptors';
import { registerLayout } from './layout';
import { registerPage } from './page'; import { registerPage } from './page';
export interface PluginAPI { export interface PluginAPI {
@@ -9,10 +10,14 @@ export interface PluginAPI {
replace: typeof replace; replace: typeof replace;
wrap: typeof wrap; wrap: typeof wrap;
registerPage: typeof registerPage; registerPage: typeof registerPage;
registerLayout: typeof registerLayout;
} }
export function createPluginAPI(): PluginAPI { export function createPluginAPI(): PluginAPI {
return { intercept, before, after, patch, replace, wrap, registerPage }; return {
intercept, before, after, patch, replace, wrap,
registerPage, registerLayout,
};
} }
export interface PluginDefinition { export interface PluginDefinition {
+1 -1
View File
@@ -30,7 +30,7 @@ function buildChain<P extends Record<string, any>>(
return pipeline; return pipeline;
} }
export function defineSlot<P extends Record<string, any>>( export function defineSlot<P extends React.ComponentProps<any>>(
name: SlotName, name: SlotName,
DefaultComp: React.FC<P>, DefaultComp: React.FC<P>,
): React.FC<P> { ): React.FC<P> {
+6 -6
View File
@@ -1,10 +1,10 @@
import type { InterceptorEntry, InterceptorOptions, SlotName } from './types'; import type { InterceptorEntry, InterceptorOptions, SlotName, SlotValue } from './types';
type Listener = () => void; type Listener = () => void;
interface RegistryState { interface RegistryState {
interceptors: Record<string, InterceptorEntry[]>; interceptors: Record<string, InterceptorEntry[]>;
defaults: Record<string, React.FC<any>>; defaults: Record<string, unknown>;
} }
function createRegistryStore() { function createRegistryStore() {
@@ -72,13 +72,13 @@ function createRegistryStore() {
return state.interceptors[name] ?? []; return state.interceptors[name] ?? [];
} }
function setDefault(name: SlotName, comp: React.FC<any>) { function setDefault<N extends SlotName>(name: N, value: SlotValue<N>) {
state.defaults[name] = comp; state.defaults[name] = value;
bumpVersion(name); bumpVersion(name);
} }
function getDefault(name: SlotName): React.FC<any> | undefined { function getDefault<N extends SlotName>(name: N): SlotValue<N> | undefined {
return state.defaults[name]; return state.defaults[name] as SlotValue<N> | undefined;
} }
return { return {
+30 -3
View File
@@ -1,17 +1,44 @@
type Scope = 'app' | 'page' | 'component' | string; import type { LayoutComponent } from './layout';
type AppScope = 'app';
type PageScope = 'page';
type LayoutScope = 'layout';
type ComponentScope = 'component';
type Scope = AppScope | PageScope | LayoutScope | ComponentScope | string;
export interface PageModule<P = any> {
default: React.ComponentType<P>;
}
export type PageLoader<P = any> = () => Promise<PageModule<P>>;
export interface PageEntry<P = any> {
Page: React.LazyExoticComponent<React.ComponentType<P>>;
layout: string;
}
export interface RegisterPageOptions {
/** Layout key registered via `registerLayout`. Defaults to `default`. */
layout?: string;
}
export type AppSlotName = `${AppScope}:${string}`;
export type PageSlotName = `${PageScope}:${string}`;
export type LayoutSlotName = `${LayoutScope}:${string}`;
export type ComponentSlotName = `${ComponentScope}:${string}`;
export type SlotName = `${Scope}:${string}`; export type SlotName = `${Scope}:${string}`;
export type SlotValue<N extends SlotName> =
N extends PageSlotName ? PageEntry
: N extends LayoutSlotName ? LayoutComponent
: N extends ComponentSlotName ? React.FC<any>
: React.FC<any>;
export type Interceptor<P = any> = ( export type Interceptor<P = any> = (
props: P, props: P,
next: (overrideProps?: Partial<P>) => React.ReactNode, next: (overrideProps?: Partial<P>) => React.ReactNode,
) => React.ReactNode; ) => React.ReactNode;
export interface InterceptorOptions { export interface InterceptorOptions {
id?: string; id?: string;
/** 越小越靠外,越先执行 */ /** 越小越靠外,越先执行 */
priority?: number; priority?: number;
} }
export interface InterceptorEntry<P = any> { export interface InterceptorEntry<P = any> {
id: string; id: string;
/** 越小越靠外,越先执行 */ /** 越小越靠外,越先执行 */