diff --git a/framework/framework/base.ts b/framework/framework/base.ts index 70ed7373c..a70f62574 100644 --- a/framework/framework/base.ts +++ b/framework/framework/base.ts @@ -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; diff --git a/packages/ui-next/index.ts b/packages/ui-next/index.ts index 08f229873..adf49f41f 100644 --- a/packages/ui-next/index.ts +++ b/packages/ui-next/index.ts @@ -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, diff --git a/packages/ui-next/src/app.tsx b/packages/ui-next/src/app.tsx index 6f2f65376..5fdb3f7dc 100644 --- a/packages/ui-next/src/app.tsx +++ b/packages/ui-next/src/app.tsx @@ -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).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 (
Page not found: {name} @@ -25,10 +47,17 @@ const App = defineSlot('app:root', () => { ); } + const Layout = store.getDefault(`layout:${entry.layout}`) ?? DefaultLayout; + const { Page } = entry; + return ( - Loading...
}> - + + + Loading...}> + + + ); diff --git a/packages/ui-next/src/components/layout.tsx b/packages/ui-next/src/components/layout.tsx index f3044a719..6700092dc 100644 --- a/packages/ui-next/src/components/layout.tsx +++ b/packages/ui-next/src/components/layout.tsx @@ -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; diff --git a/packages/ui-next/src/context/page-data.tsx b/packages/ui-next/src/context/page-data.tsx index 389214096..7f358e49a 100644 --- a/packages/ui-next/src/context/page-data.tsx +++ b/packages/ui-next/src/context/page-data.tsx @@ -4,6 +4,7 @@ import { createContext, type ReactNode, useContext, useMemo, useState } from 're export interface PageData { name: string; + template: string; args: { UserContext: Record; UiContext: Record; diff --git a/packages/ui-next/src/context/router.tsx b/packages/ui-next/src/context/router.tsx index 5253552f2..f8f50aa71 100644 --- a/packages/ui-next/src/context/router.tsx +++ b/packages/ui-next/src/context/router.tsx @@ -84,6 +84,7 @@ export const RouterProvider: React.FC = ({ 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 = ({ children }) ...prev, args: body, name: pageName, + template, url, })); dispatch({ type: 'FETCH_SUCCESS' }); diff --git a/packages/ui-next/src/globals.ts b/packages/ui-next/src/globals.ts index 32161837f..2dc3ce440 100644 --- a/packages/ui-next/src/globals.ts +++ b/packages/ui-next/src/globals.ts @@ -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), }; diff --git a/packages/ui-next/src/pages/homepage.tsx b/packages/ui-next/src/pages/homepage.tsx index 6282ca7f4..a31550ff1 100644 --- a/packages/ui-next/src/pages/homepage.tsx +++ b/packages/ui-next/src/pages/homepage.tsx @@ -8,12 +8,3 @@ export default function Homepage() { ); } - -export function Layout({ children }: React.PropsWithChildren) { - return ( -
- {children} -
layout test
-
- ); -} diff --git a/packages/ui-next/src/registry/index.ts b/packages/ui-next/src/registry/index.ts index 30ec23551..1f0cbb239 100644 --- a/packages/ui-next/src/registry/index.ts +++ b/packages/ui-next/src/registry/index.ts @@ -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'; diff --git a/packages/ui-next/src/registry/layout.ts b/packages/ui-next/src/registry/layout.ts new file mode 100644 index 000000000..e5c1ae831 --- /dev/null +++ b/packages/ui-next/src/registry/layout.ts @@ -0,0 +1,7 @@ +import { store } from './store'; + +export type LayoutComponent = React.ComponentType; + +export function registerLayout(name: string, Comp: LayoutComponent) { + store.setDefault(`layout:${name}` as `layout:${string}`, Comp); +} diff --git a/packages/ui-next/src/registry/page.tsx b/packages/ui-next/src/registry/page.tsx index bf4f87c00..a67767ed0 100644 --- a/packages/ui-next/src/registry/page.tsx +++ b/packages/ui-next/src/registry/page.tsx @@ -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

{ - default: React.ComponentType

; - Layout?: React.ComponentType; -} - -type PageLoader

= () => Promise>; - -export function registerPage

(name: string, loader: PageLoader

) { - store.setDefault(`page:${name}`, lazy(() => - loader().then((mod) => { - const PageComp = mod.default; - const LayoutComp = mod.Layout || Layout; - return { - default(props: React.PropsWithChildren

) { - return ( - - - - ); - }, - }; - }), - )); +export function registerPage

( + name: string, + loader: PageLoader

, + options: RegisterPageOptions = {}, +) { + const Page = lazy(loader); + const entry: PageEntry

= { Page, layout: options.layout ?? 'default' }; + store.setDefault(`page:${name}` as PageSlotName, entry); } diff --git a/packages/ui-next/src/registry/plugin.ts b/packages/ui-next/src/registry/plugin.ts index dc63f94ff..cdf275060 100644 --- a/packages/ui-next/src/registry/plugin.ts +++ b/packages/ui-next/src/registry/plugin.ts @@ -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 { diff --git a/packages/ui-next/src/registry/slot.tsx b/packages/ui-next/src/registry/slot.tsx index 20cc6dbbb..768b8696c 100644 --- a/packages/ui-next/src/registry/slot.tsx +++ b/packages/ui-next/src/registry/slot.tsx @@ -30,7 +30,7 @@ function buildChain

>( return pipeline; } -export function defineSlot

>( +export function defineSlot

>( name: SlotName, DefaultComp: React.FC

, ): React.FC

{ diff --git a/packages/ui-next/src/registry/store.ts b/packages/ui-next/src/registry/store.ts index 93ef08f77..4a55748b2 100644 --- a/packages/ui-next/src/registry/store.ts +++ b/packages/ui-next/src/registry/store.ts @@ -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; - defaults: Record>; + defaults: Record; } function createRegistryStore() { @@ -72,13 +72,13 @@ function createRegistryStore() { return state.interceptors[name] ?? []; } - function setDefault(name: SlotName, comp: React.FC) { - state.defaults[name] = comp; + function setDefault(name: N, value: SlotValue) { + state.defaults[name] = value; bumpVersion(name); } - function getDefault(name: SlotName): React.FC | undefined { - return state.defaults[name]; + function getDefault(name: N): SlotValue | undefined { + return state.defaults[name] as SlotValue | undefined; } return { diff --git a/packages/ui-next/src/registry/types.ts b/packages/ui-next/src/registry/types.ts index 5e71c8a85..6e7bb8825 100644 --- a/packages/ui-next/src/registry/types.ts +++ b/packages/ui-next/src/registry/types.ts @@ -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

{ + default: React.ComponentType

; +} +export type PageLoader

= () => Promise>; +export interface PageEntry

{ + Page: React.LazyExoticComponent>; + 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 PageSlotName ? PageEntry + : N extends LayoutSlotName ? LayoutComponent + : N extends ComponentSlotName ? React.FC + : React.FC; export type Interceptor

= ( props: P, next: (overrideProps?: Partial

) => React.ReactNode, ) => React.ReactNode; - export interface InterceptorOptions { id?: string; /** 越小越靠外,越先执行 */ priority?: number; } - export interface InterceptorEntry

{ id: string; /** 越小越靠外,越先执行 */