Merge remote-tracking branch 'origin/master' into worktree-locale
# Conflicts: # docs/module-graph.md # packages/client/locale/README.i18n.yaml # packages/client/locale/README.zh.md
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-slots/README.md
|
||||
README.md: ed6f052b3a47e08d693928b6763e32427b829467
|
||||
README.zh.md: 8f15352d09a33a862203507ac89841da91a43e59
|
||||
README.zh.md: 17c3cbb28defe0c9bc66df417976984be3955b53
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Slot 注册表纯核心、slot 终端设计:SlotMap 声明合并、SlotCore 上唯一的 `register` 组合 API、四 share 组件 props 类型家族、store seat 类型家族,以及 renderer 安装 seam 契约。React 类型仅在运行时使用,该包不依赖 React,也不依赖 cordis。
|
||||
Slot 注册表纯核心、slot 终端设计:SlotMap 声明合并、SlotCore 上唯一的 `register` 组合 API、四 share 组件 props 类型家族、store seat 类型家族,以及 renderer 安装 seam 契约。只使用 React 类型;该包(package)不依赖 React,也不依赖 Cordis。
|
||||
|
||||
一次 `register({ name, children?, store?, inject?, ...kind }, Component)` 调用会向已声明 slot 贡献一个组件,同时声明子 slot(声明 = 渲染授权 = 运行时规范,三者共用一张表)、store seat 以及注册方的业务表层。组件会在调用点依据 `ComposedProps` 接受检查;该类型是四个 share 的交集,每个 share 都从各自的唯一真源派生:
|
||||
|
||||
| share | 类型 | 来源 |
|
||||
|---|---|---|
|
||||
| runtime | `PropsRuntime<K>` | SlotMap 配置项:`owner`(父级 renderSlot 调用点)+ Session 标准工具包 + 全局 seat |
|
||||
| runtime | `PropsRuntime<K>` | SlotMap 条目:`owner`(父级 renderSlot 调用点)+ Session 标准工具包 + 全局 seat |
|
||||
| child render | `PropsRenderSlots<S>` | register 调用的 `children` key 集合(静态缩窄的 `renderSlot`) |
|
||||
| store | `PropsStore<H>` | 已声明 handle:`useStore` selector hook + 移除 draft 的 `actions` |
|
||||
| business | `I` | 从 `inject` factory 返回值推断 |
|
||||
|
||||
chain-kind slot 会反转键控路由:配置项自行提名,而不是由分发点选择 `entryKey`。每次注册都携带一个纯 `ChainSelect` selector(另有可选的升序 `priority`,相同值按注册顺序处理);第一个非 null 返回值选中其配置项,并成为组件的 `matched` prop;全部返回 null 时则使用 owner 的 `renderSlotChain` fallback(`ChainRenderOpts`)。
|
||||
chain-kind slot 会反转键控路由:条目自行提名,而不是由分发点选择 `entryKey`。每次注册都携带一个纯 `ChainSelect` selector(另有可选的升序 `priority`,相同值按注册顺序处理);第一个非 null 返回值选中其条目,并成为组件的 `matched` prop;全部返回 null 时则使用 owner 的 `renderSlotChain` fallback(`ChainRenderOpts`)。
|
||||
|
||||
标准工具包接口(`SessionStandardProps`、`GlobalStandardProps`)在这里声明为空,由 runtime 包合并(与 SlotMap key 相同的 declare-merge 模式)。renderer 会把运行时 Session 和 Workspace observable source 绑定为 selector hook。Inject factory 参数从声明派生(`InjectParams`):Session slot 获得 `sessionId`;声明 store 时追加 baked `actions`;没有其他参数,数据访问位于 apply 闭包的 ctx 中。
|
||||
|
||||
store 家族(输入 `defineStore` 规范/输出 `StoreHandle<T, A>`)为 store seat 建模:`init` 推断状态 schema;`actions` 是完整的 draft-transform 写入集合;`BakedActions` 移除 draft 参数,成为组件和 inject factory 收到的回调。`defineStore` 值实现位于 runtime 包(引擎所属位置),并满足这里导出的 `DefineStore` 契约。引擎产物与 renderer host 契约携带裸快照 source(`getSnapshot`/`subscribe`),绝不携带 React hook;hook 绑定属于渲染机制这一侧的 seam,只有 props 契约 hook 类型(`SnapshotSelectorHook`)位于这里。
|
||||
|
||||
`SlotCore` 在构造时播种先验的 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。配置项的 disposer 会递归折叠其声明的子 slot:账本行、贡献和 store 挂载都沿同一生命周期轴消失。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
|
||||
`SlotCore` 在构造时预置 `'root'` slot,并强制执行加载时验证(注册未声明 slot、重复声明子项、在两个 scope 下使用同一个共享 handle、chain 注册缺少 `select`,这些情况都在 register 时抛出)。条目的 disposer 会递归移除其声明的子 slot:账本行、贡献和 store 挂载都会随同一生命周期结束而移除。`renderer.ts` 携带安装 seam(`SlotRenderer`、`SlotRendererHost`)以及 `StaleAuthorizationError`/`SlotOwnershipError`;实现在 web-react 中,安装则在外壳启动中完成。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -31,5 +31,5 @@ store 家族(输入 `defineStore` 规范/输出 `StoreHandle<T, A>`)为 st
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **`isLive` 会线性扫描所有记录**:在 UI 插件的注册规模(数十项)下没有问题;如果账本变得频繁访问,再使用配置项→记录反向引用改进。
|
||||
- **`isLive` 会线性扫描所有记录**:在 UI 插件的注册规模(数十项)下没有问题;如果账本变得频繁访问,再使用条目→记录反向引用改进。
|
||||
- **`__renders` 幻象锚点在 `PropsRenderSlots` 上可见**:这是与类型链设计的 `__accepts` 相同且已接受的噪声;泛型方法签名在 key 联合之间比较宽松,因此必须依靠逆变标记强制执行「组件 key 集合 ⊆ children 声明」。
|
||||
|
||||
@@ -36,13 +36,22 @@ export interface DeferredRegistration {
|
||||
* @param name - target slot name.
|
||||
* @param component - the component whose ledger presence marks "registered".
|
||||
* @param register - performs the actual registration; returns its disposer.
|
||||
* @param onFailure - owns a registration failure that fires from a LATER
|
||||
* ledger flush (a declaration landing after two providers deferred, say):
|
||||
* the deferral first removes its own subscription, then hands the error
|
||||
* over instead of throwing through the flush — the callback's chance to
|
||||
* roll back sibling deferrals and surface the conflict on a loud channel.
|
||||
* Absent, a late failure rethrows out of the flush.
|
||||
* @returns the deferral handle (dispose in the owning effect's disposer).
|
||||
* @throws the immediate registration's failure, after removing the
|
||||
* just-installed subscription — a throwing construction leaves nothing live.
|
||||
*/
|
||||
export function deferRegistration(
|
||||
registry: DeferralRegistry,
|
||||
name: string,
|
||||
component: unknown,
|
||||
register: () => () => void,
|
||||
onFailure?: (error: unknown) => void,
|
||||
): DeferredRegistration {
|
||||
let dispose: (() => void) | undefined
|
||||
const tryRegister = (): void => {
|
||||
@@ -50,8 +59,24 @@ export function deferRegistration(
|
||||
if (registry.entries(name).some(e => e.component === component)) return
|
||||
dispose = register()
|
||||
}
|
||||
const unsubscribe = registry.subscribe(name, () => { tryRegister() })
|
||||
tryRegister()
|
||||
const unsubscribe = registry.subscribe(name, () => {
|
||||
try {
|
||||
tryRegister()
|
||||
} catch (error) {
|
||||
unsubscribe()
|
||||
if (onFailure === undefined) throw error
|
||||
onFailure(error)
|
||||
}
|
||||
})
|
||||
try {
|
||||
tryRegister()
|
||||
} catch (error) {
|
||||
// A synchronous registration failure (the declared slot is already
|
||||
// occupied) must not leave the just-installed subscription behind: the
|
||||
// caller receives no handle to dispose it through.
|
||||
unsubscribe()
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
refresh() {
|
||||
dispose?.()
|
||||
@@ -64,3 +89,40 @@ export function deferRegistration(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defer ONE occupant into several holes as a unit. Construction that throws
|
||||
* partway (a declared hole already occupied registers synchronously) rolls
|
||||
* every earlier deferral back before rethrowing; a failure surfacing from a
|
||||
* LATER ledger flush (holes declared after rival providers activated) rolls
|
||||
* the whole group back the same way and re-raises the wrapped error on the
|
||||
* global channel the boot's fail-loud handler owns — never a throw through
|
||||
* the slot flush, never partial occupancy from the group's owner.
|
||||
* @param registry - the slot registry face.
|
||||
* @param names - the target holes (one registration per name).
|
||||
* @param component - the occupant whose ledger presence marks "registered".
|
||||
* @param register - performs one hole's registration; returns its disposer.
|
||||
* @returns the group handle (dispose in the owning effect's disposer).
|
||||
* @throws the immediate registration's failure, after rolling the group back.
|
||||
*/
|
||||
export function deferGroupRegistration<K extends string>(
|
||||
registry: DeferralRegistry,
|
||||
names: readonly K[],
|
||||
component: unknown,
|
||||
register: (name: K) => () => void,
|
||||
): { dispose: () => void } {
|
||||
const deferred: DeferredRegistration[] = []
|
||||
const lateFailure = (error: unknown): void => {
|
||||
for (const entry of deferred) entry.dispose()
|
||||
queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) })
|
||||
}
|
||||
try {
|
||||
for (const name of names) {
|
||||
deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure))
|
||||
}
|
||||
} catch (error) {
|
||||
for (const entry of deferred) entry.dispose()
|
||||
throw error
|
||||
}
|
||||
return { dispose: () => { for (const entry of deferred) entry.dispose() } }
|
||||
}
|
||||
|
||||
126
packages/client/ui-slots/tests/deferred.spec.ts
Normal file
126
packages/client/ui-slots/tests/deferred.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
// deferRegistration lifecycle: declaration-aware registration, HMR
|
||||
// re-registration, and — the failure contract — no subscription survives a
|
||||
// construction that throws synchronously (an already-occupied single slot).
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
// Shares the merges declared by core.spec.ts (same program); reuse its keys.
|
||||
const HOLE = 'test.single' as const
|
||||
|
||||
function declared(): SlotCore {
|
||||
const core = new SlotCore()
|
||||
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
|
||||
return core
|
||||
}
|
||||
|
||||
describe('deferRegistration', () => {
|
||||
it('registers immediately under an existing declaration and disposes cleanly', () => {
|
||||
const core = declared()
|
||||
const component = (): null => null
|
||||
const handle = deferRegistration(core, HOLE, component, () =>
|
||||
core.register({ name: HOLE } as never, component as never))
|
||||
expect(core.entries(HOLE)).toHaveLength(1)
|
||||
handle.dispose()
|
||||
expect(core.entries(HOLE)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hands a late registration failure to onFailure after unsubscribing itself', async () => {
|
||||
const core = new SlotCore()
|
||||
const component = (): null => null
|
||||
const foreign = (): null => null
|
||||
const failures: unknown[] = []
|
||||
// Nothing is declared yet: the deferral just subscribes and waits.
|
||||
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
|
||||
deferRegistration(core, HOLE, component, register, (error) => { failures.push(error) })
|
||||
// The declaration lands with a foreign occupant racing in first: the
|
||||
// deferral's flush-time attempt fails, unsubscribes itself, and reports
|
||||
// through onFailure instead of throwing out of the flush.
|
||||
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
|
||||
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
|
||||
await Promise.resolve()
|
||||
expect(failures.map(String).join('')).toContain('already has a registration')
|
||||
// Unsubscribed: freeing the hole must not resurrect the loser.
|
||||
disposeForeign()
|
||||
await Promise.resolve()
|
||||
expect(core.entries(HOLE)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('drops its subscription when the immediate registration throws', async () => {
|
||||
const core = declared()
|
||||
const foreign = (): null => null
|
||||
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
|
||||
const component = (): null => null
|
||||
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
|
||||
// The single hole is occupied: the immediate attempt throws out of the
|
||||
// constructor, and the caller never receives a handle to dispose.
|
||||
expect(() => deferRegistration(core, HOLE, component, register)).toThrow(/already has a registration/)
|
||||
expect(register).toHaveBeenCalledOnce()
|
||||
// The subscription rolled back with it: freeing the hole flushes a
|
||||
// notification that must not resurrect the failed registration.
|
||||
disposeForeign()
|
||||
await Promise.resolve()
|
||||
expect(register).toHaveBeenCalledOnce()
|
||||
expect(core.entries(HOLE)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deferGroupRegistration', () => {
|
||||
const HOLES = ['test.single', 'test.grandchild'] as const
|
||||
|
||||
function declaredPair(): SlotCore {
|
||||
const core = new SlotCore()
|
||||
core.register({
|
||||
name: 'root',
|
||||
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
|
||||
} as never, (() => null) as never)
|
||||
return core
|
||||
}
|
||||
|
||||
it('registers the whole group and disposes it as a unit', () => {
|
||||
const core = declaredPair()
|
||||
const component = (): null => null
|
||||
const group = deferGroupRegistration(core, HOLES, component, name =>
|
||||
core.register({ name } as never, component as never))
|
||||
for (const name of HOLES) expect(core.entries(name)).toHaveLength(1)
|
||||
group.dispose()
|
||||
for (const name of HOLES) expect(core.entries(name)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rolls the group back when construction fails partway', () => {
|
||||
const core = declaredPair()
|
||||
const component = (): null => null
|
||||
core.register({ name: HOLES[1] } as never, (() => null) as never)
|
||||
expect(() => deferGroupRegistration(core, HOLES, component, name =>
|
||||
core.register({ name } as never, component as never))).toThrow(/already has a registration/)
|
||||
// The first hole's registration and subscription rolled back with it.
|
||||
expect(core.entries(HOLES[0])).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rolls the group back and re-raises loudly on a late conflict', async () => {
|
||||
const core = new SlotCore()
|
||||
const component = (): null => null
|
||||
const failures: unknown[] = []
|
||||
const onLoud = (reason: unknown): void => { failures.push(reason) }
|
||||
process.on('uncaughtException', onLoud)
|
||||
try {
|
||||
const group = deferGroupRegistration(core, HOLES, component, name =>
|
||||
core.register({ name } as never, component as never))
|
||||
// Declaration lands with a rival racing in ahead of the flush.
|
||||
core.register({
|
||||
name: 'root',
|
||||
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
|
||||
} as never, (() => null) as never)
|
||||
core.register({ name: HOLES[0] } as never, (() => null) as never)
|
||||
core.register({ name: HOLES[1] } as never, (() => null) as never)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(failures.map(String).join('')).toContain('already has a registration')
|
||||
// No partial occupancy from the group's owner survives.
|
||||
for (const name of HOLES) {
|
||||
expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0)
|
||||
}
|
||||
group.dispose()
|
||||
} finally {
|
||||
process.off('uncaughtException', onLoud)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user