mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
ui-next: layout refactor & dynamic template (#1182)
This commit is contained in:
1 parent
e39a5fa87f
commit
5d5a168572
15 files changed
+113
-54
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={null}>
|
||||
<Layout>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<Comp />
|
||||
<Page />
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</Suspense>
|
||||
</SlotErrorBoundary>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -8,12 +8,3 @@ export default function Homepage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Layout({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<div>
|
||||
{children}
|
||||
<div>layout test</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
/** 越小越靠外,越先执行 */
|
||||
|
||||
Reference in new issue
Block a user