diff --git a/packages/ui-next/api.ts b/packages/ui-next/api.ts index 71757047b..f7499b65f 100644 --- a/packages/ui-next/api.ts +++ b/packages/ui-next/api.ts @@ -7,4 +7,9 @@ export { type RouterState, useNavigate, useRouterState } from './src/context/rou export { useUrl } from './src/hooks/use-url'; // Registry -export type { ComponentName, RegistryContext } from './src/registry'; +export type { + Interceptor, InterceptorEntry, InterceptorOptions, + PluginAPI, PluginDefinition, + SlotName, +} from './src/registry'; +export { defineSlot } from './src/registry'; diff --git a/packages/ui-next/src/app.tsx b/packages/ui-next/src/app.tsx index c47277cc7..9aa6f484e 100644 --- a/packages/ui-next/src/app.tsx +++ b/packages/ui-next/src/app.tsx @@ -1,8 +1,8 @@ import { Link } from './components/link'; import { usePageData } from './context/page-data'; -import { Component } from './registry'; +import { before, defineSlot } from './registry'; -const AppInner = Component('page:app', () => { +const AppInner = defineSlot('page:app', () => { const data = usePageData(); return ( @@ -15,4 +15,13 @@ const AppInner = Component('page:app', () => { ); }); +before('page:app', () => { + console.log('before app'); + return ( + <> +
before app
+ + ); +}); + export default AppInner; diff --git a/packages/ui-next/src/main.tsx b/packages/ui-next/src/main.tsx index 445cd04fa..93b5e6d83 100644 --- a/packages/ui-next/src/main.tsx +++ b/packages/ui-next/src/main.tsx @@ -4,11 +4,10 @@ import plugins from 'virtual:hydro-plugins'; import App from './app'; import { type PageData, PageDataProvider } from './context/page-data'; import { RouterProvider } from './context/router'; -import { createRegistryContext } from './registry'; +import { installPlugin } from './registry'; -const ctx = createRegistryContext(); for (const plugin of plugins) { - plugin.apply(ctx); + installPlugin(plugin); } const injectionEl = document.getElementById('__HYDRO_INJECTION__'); diff --git a/packages/ui-next/src/registry/index.ts b/packages/ui-next/src/registry/index.ts new file mode 100644 index 000000000..e7455917d --- /dev/null +++ b/packages/ui-next/src/registry/index.ts @@ -0,0 +1,5 @@ +export { after, before, intercept, patch, replace, wrap } from './interceptors'; +export { installPlugin } from './plugin'; +export type { PluginAPI, PluginDefinition } from './plugin'; +export { defineSlot } from './slot'; +export type { Interceptor, InterceptorEntry, InterceptorOptions, SlotName } from './types'; diff --git a/packages/ui-next/src/registry/index.tsx b/packages/ui-next/src/registry/index.tsx deleted file mode 100644 index 3e45e9fab..000000000 --- a/packages/ui-next/src/registry/index.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* eslint-disable react-refresh/only-export-components */ - -import React from 'react'; - -type Scope = 'page' | 'component'; -export type ComponentName = `${Scope}:${string}`; - -interface BeforeAfterEntry { - id: string; - Comp: React.FC; -} - -interface PatchEntry { - id: string; - cb: (props: any) => any; -} - -function createRegistry() { - return { - components: {} as Record>, - before: {} as Record, - after: {} as Record, - patch: {} as Record, - }; -} - -const registry: ReturnType = import.meta.hot?.data?.registry ?? createRegistry(); -if (import.meta.hot) import.meta.hot.data.registry = registry; - -let idCounter = import.meta.hot?.data?.idCounter ?? 0; -if (import.meta.hot) { - Object.defineProperty(import.meta.hot.data, 'idCounter', { - get: () => idCounter, - set: (v: number) => { idCounter = v; }, - }); -} -function genId() { return `__auto_${++idCounter}`; } - -function upsert(list: T[], entry: T) { - const idx = list.findIndex((e) => e.id === entry.id); - if (idx !== -1) list[idx] = entry; - else list.push(entry); -} - -export function Component

>( - name: ComponentName, - Inner: React.FC

, -): React.FC

{ - if (registry.components[name]) { - console.warn(`[Registry] Component "${name}" is being re-registered.`); - } - const Wrapped: React.FC

= (props) => { - let patched = props; - for (const { cb } of registry.patch[name] ?? []) { - try { - patched = cb(patched); - } catch (e) { - console.error(`[Registry] patch error in "${name}":`, e); - } - } - return ( - <> - {(registry.before[name] ?? []).map(({ id, Comp }) => )} - - {(registry.after[name] ?? []).map(({ id, Comp }) => )} - - ); - }; - Wrapped.displayName = `Registry(${name})`; - registry.components[name] = Wrapped; - return Wrapped; -} - -export function before(name: ComponentName, Comp: React.FC, id?: string) { - const list = registry.before[name] ??= []; - upsert(list, { id: id ?? genId(), Comp }); -} - -export function after(name: ComponentName, Comp: React.FC, id?: string) { - const list = registry.after[name] ??= []; - upsert(list, { id: id ?? genId(), Comp }); -} - -export function patch(name: ComponentName, cb: (props: any) => any, id?: string) { - const list = registry.patch[name] ??= []; - upsert(list, { id: id ?? genId(), cb }); -} - -export interface RegistryContext { - before: typeof before; - after: typeof after; - patch: typeof patch; - register: typeof Component; -} - -export function createRegistryContext(): RegistryContext { - return { - before, after, patch, register: Component, - }; -} diff --git a/packages/ui-next/src/registry/interceptors.tsx b/packages/ui-next/src/registry/interceptors.tsx new file mode 100644 index 000000000..79e61934e --- /dev/null +++ b/packages/ui-next/src/registry/interceptors.tsx @@ -0,0 +1,85 @@ +import { store } from './store'; +import type { Interceptor, InterceptorOptions, SlotName } from './types'; + +export function intercept

( + name: SlotName, + interceptor: Interceptor

, + opts?: InterceptorOptions, +): () => void { + return store.addInterceptor(name, interceptor, opts); +} + +/** default priority: -100 */ +export function before

= Record>( + name: SlotName, + Comp: React.FC

, + opts?: InterceptorOptions, +): () => void { + return intercept

( + name, + (props, next) => ( + <> + + {next()} + + ), + { priority: -100, ...opts }, + ); +} + +/** default priority: 100 */ +export function after

= Record>( + name: SlotName, + Comp: React.FC

, + opts?: InterceptorOptions, +): () => void { + return intercept

( + name, + (props, next) => ( + <> + {next()} + + + ), + { priority: 100, ...opts }, + ); +} + +/** default priority: -50 */ +export function patch

( + name: SlotName, + patcher: (props: P) => Partial

, + opts?: InterceptorOptions, +): () => void { + return intercept

( + name, + (props, next) => next(patcher(props)), + { priority: -50, ...opts }, + ); +} + +/** default priority: 0 */ +export function replace

= Record>( + name: SlotName, + Replacement: React.FC

, + opts?: { id?: string, priority?: number }, +): () => void { + return intercept

( + name, + (props, _next) => , + { priority: 0, ...opts }, + ); +} + +/** default priority: -10 */ +export function wrap

= Record>( + name: SlotName, + Wrapper: React.FC<{ children: React.ReactNode } & P>, + opts?: InterceptorOptions, +): () => void { + return intercept

( + name, + (props, next) => {next()}, + { priority: -10, ...opts }, + ); +} diff --git a/packages/ui-next/src/registry/plugin.ts b/packages/ui-next/src/registry/plugin.ts new file mode 100644 index 000000000..3f0b585c1 --- /dev/null +++ b/packages/ui-next/src/registry/plugin.ts @@ -0,0 +1,25 @@ +import { after, before, intercept, patch, replace, wrap } from './interceptors'; + +export interface PluginAPI { + intercept: typeof intercept; + before: typeof before; + after: typeof after; + patch: typeof patch; + replace: typeof replace; + wrap: typeof wrap; +} + +export function createPluginAPI(): PluginAPI { + return { intercept, before, after, patch, replace, wrap }; +} + +export interface PluginDefinition { + name: string; + setup: (api: PluginAPI) => (() => void) | void; +} + +export function installPlugin(plugin: PluginDefinition): () => void { + const api = createPluginAPI(); + const cleanup = plugin.setup(api); + return () => cleanup?.(); +} diff --git a/packages/ui-next/src/registry/slot.tsx b/packages/ui-next/src/registry/slot.tsx new file mode 100644 index 000000000..2c017868c --- /dev/null +++ b/packages/ui-next/src/registry/slot.tsx @@ -0,0 +1,44 @@ +import { useMemo, useSyncExternalStore } from 'react'; +import { store } from './store'; +import type { InterceptorEntry, SlotName } from './types'; + +function buildChain

>( + interceptors: InterceptorEntry

[], + DefaultComp: React.FC

, +): (props: P) => React.ReactNode { + let pipeline: (props: P) => React.ReactNode = (props) => ; + + for (let i = interceptors.length - 1; i >= 0; i--) { + const { interceptor } = interceptors[i]; + const downstream = pipeline; + + pipeline = (props: P) => + interceptor(props, (overrideProps) => + downstream(overrideProps ? { ...props, ...overrideProps } : props), + ); + } + + return pipeline; +} + +export function defineSlot

>( + name: SlotName, + DefaultComp: React.FC

, +): React.FC

{ + store.setDefault(name, DefaultComp); + + const subscribeSlot = (cb: () => void) => store.subscribe(name, cb); + const getSnapshot = () => store.getVersion(name); + + const SlotComponent: React.FC

= (props) => { + const version = useSyncExternalStore(subscribeSlot, getSnapshot); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const chain = useMemo(() => buildChain(store.getInterceptors(name), store.getDefault(name)!), [version]); + + return <>{chain(props)}; + }; + + SlotComponent.displayName = `Slot(${name})`; + return SlotComponent; +} diff --git a/packages/ui-next/src/registry/store.ts b/packages/ui-next/src/registry/store.ts new file mode 100644 index 000000000..5838af9b7 --- /dev/null +++ b/packages/ui-next/src/registry/store.ts @@ -0,0 +1,94 @@ +import type { InterceptorEntry, InterceptorOptions, SlotName } from './types'; + +type Listener = () => void; + +interface RegistryState { + interceptors: Record; + defaults: Record>; +} + +function createRegistryStore() { + const state: RegistryState = { + interceptors: {}, + defaults: {}, + }; + + const slotListeners: Record> = {}; + const versions: Record = {}; + + let idCounter = 0; + const genId = () => `__slot_${++idCounter}`; + + function subscribe(name: SlotName, cb: Listener) { + const set = (slotListeners[name] ??= new Set()); + set.add(cb); + return () => set.delete(cb); + } + + function notify(name: SlotName) { + slotListeners[name]?.forEach((l) => l()); + } + + function getVersion(name: SlotName): number { + return versions[name] ?? 0; + } + + function bumpVersion(name: SlotName) { + versions[name] = (versions[name] ?? 0) + 1; + notify(name); + } + + function addInterceptor

( + name: SlotName, + interceptor: (props: P, next: (overrideProps?: Partial

) => React.ReactNode) => React.ReactNode, + opts?: InterceptorOptions, + ): () => void { + const entry: InterceptorEntry

= { + id: opts?.id ?? genId(), + priority: opts?.priority ?? 0, + interceptor, + }; + + const list = (state.interceptors[name] ??= []); + const idx = list.findIndex((e) => e.id === entry.id); + if (idx !== -1) list[idx] = entry; + else list.push(entry); + + list.sort((a, b) => a.priority - b.priority); + bumpVersion(name); + + return () => { + const arr = state.interceptors[name]; + if (!arr) return; + const i = arr.findIndex((e) => e.id === entry.id); + if (i !== -1) { + arr.splice(i, 1); + bumpVersion(name); + } + }; + } + + function getInterceptors(name: SlotName): InterceptorEntry[] { + return state.interceptors[name] ?? []; + } + + function setDefault(name: SlotName, comp: React.FC) { + state.defaults[name] = comp; + } + + function getDefault(name: SlotName): React.FC | undefined { + return state.defaults[name]; + } + + return { + subscribe, + getVersion, + addInterceptor, + getInterceptors, + setDefault, + getDefault, + }; +} + +export const store: ReturnType = import.meta.hot?.data?.slotStore ?? createRegistryStore(); +if (import.meta.hot) import.meta.hot.data.slotStore = store; diff --git a/packages/ui-next/src/registry/types.ts b/packages/ui-next/src/registry/types.ts new file mode 100644 index 000000000..4913fff37 --- /dev/null +++ b/packages/ui-next/src/registry/types.ts @@ -0,0 +1,20 @@ +type Scope = 'page' | 'component' | 'layout' | string; +export type SlotName = `${Scope}:${string}`; + +export type Interceptor

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

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

{ + id: string; + /** 越小越靠外,越先执行 */ + priority: number; + interceptor: Interceptor

; +} diff --git a/packages/ui-next/src/vite-env.d.ts b/packages/ui-next/src/vite-env.d.ts index 617da4f3f..4fc67de75 100644 --- a/packages/ui-next/src/vite-env.d.ts +++ b/packages/ui-next/src/vite-env.d.ts @@ -1,7 +1,7 @@ /// declare module 'virtual:hydro-plugins' { - import type { RegistryContext } from './registry'; - const plugins: Array<{ apply: (ctx: RegistryContext) => void }>; + import type { PluginDefinition } from './registry'; + const plugins: PluginDefinition[]; export default plugins; }