mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +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());
|
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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
@@ -8,12 +8,3 @@ export default function Homepage() {
|
|||||||
</div>
|
</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 { 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';
|
||||||
@@ -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 { 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>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
/** 越小越靠外,越先执行 */
|
/** 越小越靠外,越先执行 */
|
||||||
|
|||||||
Reference in new issue
Block a user