# 设置页插槽契约(DSH 0.1.5-rc.1)
本文记录 `dsh-plugin-desk` 浏览器半接入官方「设置 → 插件」页时踩到的契约要求,以及最终跑通的写法。所有结论都基于 DSH 源码 + 本机运行环境的实测。
## 一句话结论
**注册值必须是「函数组件」,返回 ReactNode。** 返回 DOM 节点或 `{ render() }` 对象都会被 React 拒绝,并把整个设置 section 打挂(页面变成空的 `
`)。
## 组件形态
源码里的权威定义(`packages/client/ui-slots/src/index.ts`):
```ts
/** Registration-position component shape: the bare call signature. */
export type SlotComponent
= (props: P) => ReactNode
```
渲染器用 `jsx`/`createElement` 实例化它(`packages/client/ui-renderer/src/.../renderEntry`):
```js
return jsx(Comp, { ...kit, ...injected, ...slotInjected.props, ...contextual, ...ownerProps })
```
所以合法形态只有一种——**函数组件**。
## React 从哪来
DSH 的 web shell 会把一组平台模块注入「冻结模块表」,client bundle 里 `require()` 拿到的就是这张表(`packages/client/web/src/seed.ts`):
```ts
export function getStaticModules(): Record {
return {
'react': React,
'react/jsx-runtime': ReactJsxRuntime,
'react-dom': ReactDom,
'react-dom/client': ReactDomClient,
'@deepseek-ai/cordis': Cordis,
'@deepseek-ai/dsh-client-store': ClientStore,
'@deepseek-ai/dsh-client-ui-slots': UiSlots,
'@deepseek-ai/dsh-client-ui-primitives': UiPrimitives,
'@deepseek-ai/dsh-client-ui-dockkit': UiDockkit,
}
}
```
因此 **bundle 里不需要 import 语句**,直接 `require('react')` 即可;`require` 的查找顺序是:种子表 → 已物化模块 → 已注册工厂,未命中就抛错(`packages/client/modules/src/client/system.ts`)。
**关键纪律:`require` 必须在模块作用域解析一次,绝不能放在组件函数体内。** 组件体内抛错会在渲染期炸掉整个 section——这正是排查过程中最耗时的一个假象。
## 页面本体怎么画
两条路都可行:
1. **纯 React**:组件返回 `react.createElement(...)` 树。
2. **命令式 DOM + 容器**:组件返回一个容器元素,用回调 ref 在 React 提交后把已构建好的 DOM 挂进去。本插件用这条,因为它复用了宿主半已经写好的 `buildPage()`。
```js
function PluginManagerTab() {
if (react === null) return null
return react.createElement('div', { className: 'dpm-root', ref: mountOnce })
}
function mountOnce(node) {
if (node === null || node.dataset.dpmMounted === '1') return
node.dataset.dpmMounted = '1'
node.append(buildPage())
}
```
回调 ref 在 React **提交之后**才被调用,此时节点已有父节点,可以安全挂载。(曾尝试在组件体内用 `queueMicrotask` 自己做挂载,失败:微任务早于提交执行,拿不到已提交的节点。)
## 注册
```js
ctx.effect(() => ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab',
id: 'plugin-manager',
order: 20,
label: () => '插件管家'
}, PluginManagerTab)), 'plugin-manager: settings tab')
```
- 用 `ctx.slots.inject(name, factory)` 等待目标插槽被声明;直接 `slots.register` 到未声明的插槽会报错。
- 组件作为 `register` 的**第二个参数**传入。
- 官方同一插槽的既有标签是 `插件配置`(id `configurable`)与 `插件列表`(id `all`);`order: 20` 排在其后。
## 实测对照(本机浏览器,逐步读取真实 DOM)
| 注册形态 | 结果 |
| --- | --- |
| 返回 DOM 节点 | `data-slot-error="settings.section"`,面板空白 |
| 返回 `{ render() }` 对象 | 同上 |
| 组件体内 `require('react')` | 同上(渲染期抛错) |
| **函数组件 + `react.createElement` + 回调 ref** | **标签出现,5 张卡片正常渲染,`data-slot-error` 为空** |
最终实测输出:
```
tabs = ["插件配置", "插件列表", "插件管家"]
errNodes = []
cardCount = 5
countText = "5 个插件 · 5 个有说明 · 已过滤 154 个非插件依赖"
点击「停用」 → ✅ 停用成功 · fiber 已 dispose · 已记住该选择;徽章变「已停用」,按钮变「启用」
点击「启用」 → ✅ 启用成功 · fiber 重新装配完成;徽章回「运行中」,按钮回「停用」
```
## 社区插件里的反例
`dsh-super-injector` 的 client 半用的是 `component: () => ({ render() {...} })`。按上述契约,这种形态不会被 React 正确实例化——它的设置页在同一个宿主上同样是空白的。
## 复现
1. 安装本插件并重启 `dsh web`。
2. 强制刷新页面(`Ctrl+Shift+R`),打开 设置 → 插件。
3. 观察 `[...document.querySelectorAll('[data-slot-error]')]` 应为空,且存在「插件管家」标签。