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
+114 -55

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());
if (inject.includes('pagename')) {
ctx.set('x-hydro-page', ctx._matchedRouteName || '');
ctx.set('x-hydro-template', response.template || '');
}
if (response.body !== null && typeof response.body === 'object') {
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({
HYDRO_INJECTED: true,
name: context.handler.context._matchedRouteName,
template: context.handler.response.template || '',
args: {
UserContext: context.UserContext,
UiContext: context.handler.UiContext,
@@ -224,6 +225,7 @@ export async function apply(ctx: Context) {
const serialized = JSON.stringify({
HYDRO_INJECTED: true,
name: context.handler.context._matchedRouteName,
template: context.handler.response.template || '',
args: {
UserContext: context.UserContext,
UiContext: context.handler.UiContext,
+37 -8
View File
@@ -1,12 +1,36 @@
import { Suspense, useMemo, useSyncExternalStore } from 'react';
import DefaultLayout from './components/layout';
import { usePageData } from './context/page-data';
import { defineSlot, SlotName } from './registry';
import { defineSlot } from './registry';
import { SlotErrorBoundary } from './registry/error-boundary';
import { store } from './registry/store';
const App = defineSlot('app:root', () => {
const { name } = usePageData();
const slotName: SlotName = `page:${name}`;
const { name, template, args } = usePageData();
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(() => [
(cb: () => void) => store.subscribe(slotName, cb),
@@ -15,9 +39,7 @@ const App = defineSlot('app:root', () => {
useSyncExternalStore(subscribe, getSnapshot);
const Comp = store.getDefault(slotName);
if (!Comp) {
if (!entry) {
return (
<div>
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 (
<SlotErrorBoundary slotName={slotName} label="renderer">
<Suspense fallback={<div>Loading...</div>}>
<Comp />
<Suspense fallback={null}>
<Layout>
<Suspense fallback={<div>Loading...</div>}>
<Page />
</Suspense>
</Layout>
</Suspense>
</SlotErrorBoundary>
);
+4 -1
View File
@@ -1,5 +1,8 @@
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;
@@ -4,6 +4,7 @@ import { createContext, type ReactNode, useContext, useMemo, useState } from 're
export interface PageData {
name: string;
template: string;
args: {
UserContext: 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}`);
const body = await res.json();
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);
if (gen !== genRef.current) return false;
@@ -95,6 +96,7 @@ export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children })
...prev,
args: body,
name: pageName,
template,
url,
}));
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 = {
name: (injectionData.name as string) || '',
template: (injectionData.template as string) || '',
args: (injectionData.args as any) || {},
url: (injectionData.url as string) || (window.location.pathname + window.location.search),
};
-9
View File
@@ -8,12 +8,3 @@ export default function Homepage() {
</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 { after, before, intercept, patch, replace, wrap } from './interceptors';
export { registerLayout } from './layout';
export type { LayoutComponent } from './layout';
export { registerPage } from './page';
export { installPlugin } from './plugin';
export type { PluginAPI, PluginDefinition } from './plugin';
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 Layout from '../components/layout';
import { lazy } from 'react';
import { store } from './store';
import type { PageEntry, PageLoader, PageSlotName, RegisterPageOptions } from './types';
interface PageModule<P = any> {
default: React.ComponentType<P>;
Layout?: React.ComponentType<React.PropsWithChildren>;
}
type PageLoader<P = any> = () => Promise<PageModule<P>>;
export function registerPage<P = any>(name: string, loader: PageLoader<P>) {
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>
);
},
};
}),
));
export function registerPage<P = any>(
name: string,
loader: PageLoader<P>,
options: RegisterPageOptions = {},
) {
const Page = lazy(loader);
const entry: PageEntry<P> = { Page, layout: options.layout ?? 'default' };
store.setDefault(`page:${name}` as PageSlotName, entry);
}
+6 -1
View File
@@ -1,4 +1,5 @@
import { after, before, intercept, patch, replace, wrap } from './interceptors';
import { registerLayout } from './layout';
import { registerPage } from './page';
export interface PluginAPI {
@@ -9,10 +10,14 @@ export interface PluginAPI {
replace: typeof replace;
wrap: typeof wrap;
registerPage: typeof registerPage;
registerLayout: typeof registerLayout;
}
export function createPluginAPI(): PluginAPI {
return { intercept, before, after, patch, replace, wrap, registerPage };
return {
intercept, before, after, patch, replace, wrap,
registerPage, registerLayout,
};
}
export interface PluginDefinition {
+1 -1
View File
@@ -30,7 +30,7 @@ function buildChain<P extends Record<string, any>>(
return pipeline;
}
export function defineSlot<P extends Record<string, any>>(
export function defineSlot<P extends React.ComponentProps<any>>(
name: SlotName,
DefaultComp: 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;
interface RegistryState {
interceptors: Record<string, InterceptorEntry[]>;
defaults: Record<string, React.FC<any>>;
defaults: Record<string, unknown>;
}
function createRegistryStore() {
@@ -72,13 +72,13 @@ function createRegistryStore() {
return state.interceptors[name] ?? [];
}
function setDefault(name: SlotName, comp: React.FC<any>) {
state.defaults[name] = comp;
function setDefault<N extends SlotName>(name: N, value: SlotValue<N>) {
state.defaults[name] = value;
bumpVersion(name);
}
function getDefault(name: SlotName): React.FC<any> | undefined {
return state.defaults[name];
function getDefault<N extends SlotName>(name: N): SlotValue<N> | undefined {
return state.defaults[name] as SlotValue<N> | undefined;
}
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 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> = (
props: P,
next: (overrideProps?: Partial<P>) => React.ReactNode,
) => React.ReactNode;
export interface InterceptorOptions {
id?: string;
/** 越小越靠外,越先执行 */
priority?: number;
}
export interface InterceptorEntry<P = any> {
id: string;
/** 越小越靠外,越先执行 */