Files
imccyu 32aba5ea37 docs: address review comments on the rules directory
- package-surface: split the two plugin forms (a function plugin named-exports
  the Loader four, a service plugin default-exports its class), keep Host
  Service Definition exports and the Client `/client` export discipline, replace
  the wrong example that quoted live production code, state that the static
  client libraries and `packages/util/*` are closed to new members, and drop
  census numbers that rot on the next package.
- client-components: use the real Slot API — the inject face with the reserved
  `hooks` compartment, the parent's derived `renderSlot`, `registerFactory`
  scope declarations with local components at `renderFactorySlot` — drop the
  string seat, pass the injected `useResource` explicitly with a protocol key,
  and describe persistence as the spec's framework-run `persist` key.
- conversation-and-session: rewrite the reasoning example on the real
  `conversation.chat.reasoning.body` seat and the published `reasoning.content`
  Factory, and drop status annotations and future-tense spec-speak.
- remote-and-api: scope the JSON requirement to the wire payload so
  `@Remote({ mode: 'stream' })`, lookup arguments, and a trailing `AbortSignal`
  stay valid; drop a leaked Chinese parenthetical from the English page.
- general: measure through `benchmarks/` (`test:bench`, `check:ci:bench`,
  `test:bench:memory`) instead of an invented command, state the baseline
  without presuming a hook, and drop a leaked exception line.
- api-gateway: link the Remote and API rules instead of restating them.
- development.zh and AGENTS: drop an orphan anchor and widen the standing
  order's trigger to the directory's scope.

The earlier "retired routing skill" claim was wrong and is dropped: that skill
was never committed, so the diff deletes nothing under `.agents/skills/`.
2026-10-09 23:03:57 +08:00

29 KiB
Raw Permalink Blame History

客户端组件规则

English | 中文

关于客户端组件、其状态与 effect 的规则。详细纪律见 packages/client/AGENTS.md;Slot 设计正家是 slots.zh.md,样式规则正家是 web-styling.zh.md。以下每一节给出要求、评审判定方式、边界与例外,以及一正一反两个例子。

状态经 Injection 到达

Slot 的组件经自己注册项的 inject 面获得它渲染所需的一切——普通数据、action 回调,以及渲染器绑定成 use<Name> hook 的保留 hooks 隔间——而父组件的渲染点经派生的 renderSlot 传 owner 数据。它绝不先在顶层读一棵大全局状态再逐层手工传下去。把每个活值都放在那个面上,props 才能在没变时保持引用不变,组件也才能只订阅自己读取的那部分子状态。缺了这条,任何一个无关字段变化都会重渲整棵子树——长会话在流式事件下的掉帧、滚动与输入卡顿就是这么来的。

  • 判定方式:在注册项与其渲染点上,作者都必须能回答两件事——被渲染组件订阅了哪些子状态,以及它收到的哪些 props 在多次渲染之间保持引用稳定。要盯住每个 prop 的可变性:每次渲染都重建的值(新对象、新数组、内联回调)即使内容没变也会向下传播重渲。
  • 不在范围内:组件自己的局部状态(折叠、悬停、草稿用的 useState)不属于注入内容;不订阅 React 之外任何东西的组件也不需要新席位。
  • 例外:新增 hook 席位会改动 Slot 契约,因此该改动要在 review 中说明影响面——哪些组件订阅什么;使用既有注入面不需要逐组件批准。
  • 修法:把注入收窄到该视图真正渲染的内容,每个读取项经一个 hook 席位暴露一个字段,用窄 action 集合取代 store 或 service 句柄。组件输入 正家负责 inject 工厂、标准 hook 席位,以及它们各自如何成为 prop。

注册项承载普通数据、一组窄 action 与一个 hook 席位;渲染点只传 owner 数据,组件只读它渲染的那个字段:

// The inject factory returns plain data and callbacks; hooks is the reserved compartment.
ctx.slots.register(
  {
    name: 'sidebar.right',
    inject: () => ({
      rename,
      hooks: { info: infoSource },   // bound by the renderer as useInfo()
    }),
  },
  SidebarRight,
)

// The parent's render site passes owner data through the derived renderSlot.
{renderSlot('sidebar.right', { id })}

function SidebarRight({ id, useInfo, rename }: Props) {
  const { title } = useInfo()   // subscribes to title only
  return <button onClick={() => rename(id)}>{title}</button>
}

顶层组件读了整个 view store 并把它作为一个 prop 传下去,于是渲染点再也回答不了上面两个问题:

function Sidebar() {
  const view = useViewStore()           // one top-level global read
  return <SidebarRight view={view} />   // the whole store travels down
}

function SidebarRight({ view }: { view: ViewStore }) {
  return <span>{view.activePanel.title}</span>
}

该 store 里任何字段变化都会重渲整个侧栏子树,而且绑定它的渲染点完全看不到这个组件订阅了什么。

props 承载数据与回调,不承载 React 节点

prop 是数据或回调;React 节点经声明的 slot 传递。把元素作为 prop 值或注入成员传下去,会藏起一条 Slot 契约没有描述的第二条组合路径,把渲染控制权从注册面挪到调用点,并让一个包去拼装另一个包的标记。新增的节点型 prop 或注入成员一律禁止;需要宿主提供标记的位置,改为声明一个 child slot。

  • 判定方式:组件的 props 类型、inject 工厂的返回值、或渲染点的 owner props 对象里出现 ReactNode、ReactElement 或 () => JSX.Element。
  • 不在范围内:已经发布的节点型字段继续可用。规则在改动波及它时触发,该改动把它改掉,而不是继续扩展它。
  • 例外:同一改动内无法完成改造时,由对应领域专家确认延期,且改动中记录该确认;作者不得自行确认。
  • 修法:在父级注册的 children 中声明该位置,用 renderSlot 渲染它,数据经子组件的 props 传入。扩展规则 正家负责哪个包可以声明和渲染哪个 slot。

头部位置是一个声明的 child slot,因此宿主只传数据,标记由注册组件自己决定:

// The card declares the header location; the host passes data only.
ctx.slots.register(
  {
    name: 'tool.call.toolview',
    children: { header: { kind: 'single', scope: 'session' } },
  },
  ToolCard,
)

function ToolCard({ title, renderSlot }: Props) {
  return <article>{renderSlot('header', { title })}</article>
}

React 节点作为 prop 值越过边界,于是它所走的组合路径没有任何地方声明过:

type ToolCardProps = {
  header?: React.ReactNode
  footer?: React.ReactNode
}

// The call site assembles another package's markup.
<ToolCard header={<ArchiveBadge />} />

卡片的 Slot 契约不再描述它渲染什么,调用点继续握着渲染控制权,而徽章所在的包变成了传节点方的运行时引用。

slot 注册物是稳定的 function component

注册进 slot 的值是在模块作用域声明的稳定 function component。渲染期创建的组件函数每一轮都是新的组件类型,而 React 把变化的类型当作另一个组件:它卸载这棵子树并挂载一棵新的,展开的行、输入焦点与滚动位置随之重置。注册期参数经 props 到达组件,而不是靠闭包捕获渲染期变量。

  • 判定方式:注册物在哪里创建。注册调用或渲染调用里的内联箭头、渲染函数体内定义的组件、以及用 useCallback 包住内联组件的写法都不合格;模块作用域的 function 声明合格。
  • 不在范围内:本条约束的是注册物的身份,不是它的复杂度。稳定组件照样可以读取注入的 props、hooks 与回调。
  • 例外:只有调用方确实想要这次重挂载时,渲染期创建组件才是对的,普通改动无需单独批准;Slot 契约形态的变更按「状态经 Injection 到达」一节描述的注入面 review 走。
  • 修法:把组件移到模块作用域,用注册面的各个 share 给它的 props 定型,让变化的值经 inject 面传入而不是走闭包。

注册项持有对模块作用域组件的稳定引用,变化的值经注入到达;局部组件由渲染点传入:

function PanelTitle({ title, count }: Props) {
  return <span>{title} ({count})</span>
}

// Registration: the local slot's scope is declared, never a component.
ctx.slots.registerFactory(
  {
    name: 'panel',
    scope: 'root',
    slots: { title: { scope: 'root' } },
    inject: () => ({ title: 'Sessions', count: 3 }),
  },
  Panel,
)

// The render site passes the local component, keeping its identity stable.
renderFactorySlot('panel', { /* owner data */ }, { slots: { title: PanelTitle } })

渲染调用里的内联箭头会在该次渲染运行时产生新的组件类型:

// A new component type each time this render runs.
renderFactorySlot('panel', { /* owner data */ }, { slots: { title: (p) => <span>{p.title}</span> } })

React 看到的类型与上一次注册不同,于是重挂载该席位,并在一次无关更新中重置它的局部状态——展开态、焦点或滚动位置。

组件绝不接收 Cordis context

ctx 属于 apply 世界:插件主体与闭包持有它的 inject 工厂。组件不接收 Cordis context、不导入 service 类去戳它、也不读 React context,因为业务组件看不到任何 context——渲染器的 binding context 是它内部的。裸 observable 只有在 Injection 把它包成 hook 之后才到达组件;直接自行订阅的组件持有一份 Injection 面上无人释放的生命周期。

  • 判定方式:组件的参数或 props 类型里出现 ctx 或 Context;组件体内读 observable.value、调用 subscribe、或接 useSyncExternalStore;feature 的 .tsx 模块里导入 service 类。
  • 不在范围内:组件内部、不订阅 React 之外任何东西的行为 hook(计时器、媒体查询)不是注入内容,留在组件里。
  • 例外:无。TabSlot 里现存的直接订阅是该席位 owner 的整改工作,不是新代码的先例。
  • 修法:把该事实移进注册的 inject——纯数据与回调,裸 observable 放进保留的 hooks 隔间——或移进声明的 store,再经派生的席位读取。

Injection 绑定 observable,组件读一个派生的 hook,订阅生命周期由渲染器持有:

// The inject factory is the only place that closes over ctx.
const inject = () => ({
  hooks: { tab: tabStore.observable },
  action: { openTab },
})

function TabView({ useTab, action }: Props) {
  const { loaded } = useTab()   // the renderer owns the subscription
  return <button hidden={loaded} onClick={() => action.openTab('files')} />
}

组件持有 ctx 并自己读源,于是订阅及其生命周期都归组件:

function TabView({ ctx }: { ctx: Context }) {
  const store = ctx.get('tabStore')                     // component holds the container
  return <span>{store.observable.value.title}</span>    // raw observable read in render
}

没有人释放这个订阅,重渲在 React 的调度之外触发,组件也就此绑死在某一个 store 实现上。

上下文由当前 scope 给出

session、panel、tab 一类上下文由当前 scope 给出——Provider、注入成员或框架 hook。新增全局 current、active、selection、viewId 都不是取用它们的通道,因此同一 session 下的多个 composer、多个 conversation 才能各自独立工作。

判定条件是:组件经 scope 取得上下文,还是经全局字段取得。多实例行为就是检验:同一 session 下两个 composer 不得串台。确实全局唯一的对象由服务或实例承载,调用方显式 inject。

// The injected factory closes over the scope's own session and panel.
interface Scope { readonly session: string; readonly panel: string }
const inject = (ctx: Scope) => ({ session: ctx.session, panel: ctx.panel })
const activeTabId = globalThis.__activeTabId   // every tab reads the same value

反例让实例之间互相串台,状态的归属也无法审计。

资源消费方走 useResource

展示资源的元素经注入的 useResource hook 读取它,地址由资源的协议与地址给出。领域包装——一个 Tab、一个面板——在 useResource 之上组合自己的 hook,返回结构自由,但底下的取数、缓存与订阅不得重做。每个协议各来一条读取路径会把缓存与失效策略劈成两半,同一个地址可能在一处是活的、在另一处是旧的。

  • 判定方式:读取路径。useResource<Protocol>(address),或内部调用它的业务 hook,合格;自建订阅加缓存、复制提供方的流处理、或 useTabStream 这类压平式通用 hook 不合格。
  • 不在范围内:与资源无关的局部 UI 状态——折叠、悬停、草稿——留在 useState,不给它资源地址。客户端资源 正家负责各个 status 的含义以及什么让资源保持打开。
  • 例外:重塑快照自由,但把协议差异藏进条件分支的包装要在 review 中说明归属;新协议的 hook 由该协议的拥有者提供。
  • 修法:在领域 hook 内调用 useResource,把结果映射成组件要渲染的内容,删掉自建缓存。

领域包装接收注入的 hook 作为参数,并且只重塑其结果,底层因此保持唯一:

// The bottom layer is the standard hook, reached through the component's inject face.
function useChatMessages(useResource: UseResource, id: string) {
  const resource = useResource<'chat'>(`dsh-resource://chat/${id}`)
  return resource.status === 'live' && resource.value !== undefined
    ? { messages: resource.value.messages, loading: false }
    : { messages: [], loading: true }
}

function ChatBody({ id, useResource }: Props) {
  const { messages } = useChatMessages(useResource, id)
  return <ol>{messages.map((message) => <li key={message.id}>{message.text}</li>)}</ol>
}

包装以自己的名义重做了订阅、缓存与失效,于是提供方的生命周期与共享缓存都不再适用:

export function useTabStream(id: string) {
  const [value, setValue] = useState<TabData>()            // bespoke cache
  useEffect(() => {
    const subscription = tabProtocol.open(id).subscribe(setValue)   // bespoke subscription
    return () => subscription.close()                              // bespoke teardown
  }, [id])
  return value
}

每个协议各长出一套取数与失效逻辑,缓存条目与刷新策略无法统一,同一地址的消费方对当前值各执一词。

Store 用法窄而明确

前端 store 承载共享的查看或交互状态。它的 spec 声明初始状态、改动这些状态的 action,以及在值需要跨重载存活时由框架机械执行的持久化键——session 作用域的实例会在该键上追加 scope key,因此每个会话的实例各自独立持久化。store 的属性与改动这些属性的 action 被封装成窄接口注入,组件只拿到这些以及它需要的选择器,绝不拿整个 view store。业务数据住在对象层而不是 store 里,设置变更走设置接口,不走 store 状态。

  • 判定方式:注入面是窄接口还是 store 对象本身,以及句柄是在 apply 内构建还是在模块级构建。
  • 持久化:spec 的 persist 键就是全部机制,由框架执行;引擎实例暴露 getSnapshot、subscribe、烘焙好的 actions 与 clearPersisted,没有任何一方手工 hydrate。
  • 不在范围内:不改设置时,渲染状态放 store 没问题;组件局部状态也不是 store 内容。Store 契约 正家负责声明的 store 如何变成 props,Web 客户端架构 正家负责业务数据住在哪里。
  • 例外:在模块级导出 store 句柄一律禁止——模块缓存的身份在插件重载之间是一个伪装的单例;把注入面收窄成窄接口不需要单独批准。
  • 修法:把 inject 返回值里的 store 句柄换成组件渲染所需的字段与 action 回调,句柄留在 apply 内。

一个句柄在 apply 内构建并传给注册项,inject 面只带视图渲染所需的字段与 action:

// The handle is built in apply; persistence is the spec's own key.
const sidebarStore = createSidebarStore()   // defineStore({ init, actions, persist: 'sidebar' })

ctx.slots.register(
  { name: 'sidebar.header', store: sidebarStore, inject: () => ({ onSelect: selectPanel }) },
  SidebarHeader,
)

// The component reads through the framework-bound seat and writes through the actions.
function SidebarHeader({ useStore, actions, onSelect }: Props) {
  const { title } = useStore()
  return <button onClick={onSelect}>{title}</button>
}

整个 view store 被注入给组件自行挑选字段,模块级句柄则把实例生命周期从框架手里拿走:

// The component receives the store itself and picks its own fields.
const inject = () => ({ viewStore })

function SidebarHeader({ viewStore }: Props) {
  return <button onClick={viewStore.select}>{viewStore.activePanel.title}</button>
}

// Module-level handle: one identity shared by every plugin load.
export const paneStore = createPaneStore()

组件的依赖面不再可审计,而共享的模块身份活得比任何一次 scope 清理都久:两个 pane 会落到同一个实例上,而不是各自一个。

身份与初始状态都是刻意的

状态切换不重建控件、也不替换其 DOM 节点:实例与其 DOM 身份在状态变化之间保持同一,焦点、选中与进行中的草稿因此得以保留。初始状态在首个流程运行时即就绪——同一条路径既准备数据也建立状态——而不是之后由 effect 回填。组件接收 hooks、选择器与 action 回调,而不是整个 service。

  • 判定方式:状态变化之后,控件实例与其 DOM 节点是否保持同一身份;出现卸载再挂载即违反。初始化路径同样判定:状态在首个流程内建立合格,从 effect 写入不合格。
  • 依赖面:hooks 读取、选择器选中子状态、action 回调写回。整个 service 作为 prop 或注入成员传入即不合格。
  • 范围:带内部实例与本地状态的输入与命令控件,以及由 factory 创建的业务视图。
  • 例外:确需重挂载(切换的数据集其实例状态不可迁移)时,在 review 中连同状态迁移方案一并说明;由评审该控件实例生命周期改动的评审者接受。
  • 修法:在首个流程建立状态的地方派生初始值,保持布局节点挂载、只变化其 props,并用三个窄 share 取代 service prop。

草稿随会话一起建立,composer 节点保持挂载,只有 props 随状态变化:

// The draft is built by the first flow; nothing fills it in later.
const draft = useDraftState(sessionId)
const { canSend } = useComposerSelectors(draft)

// The node stays mounted; state changes reach it as props.
return (
  <div className={s.composer} data-ready={canSend || undefined}>
    {draft.text}
  </div>
)

初始状态迟一个提交到达,带 key 的控件在每次会话切换时被重建:

// The draft is filled in after the first commit.
useEffect(() => { setDraftState(initialDraft) }, [])

// The keyed composer unmounts on every session switch.
<Composer service={composerService} key={sessionId} />

首帧渲染出空草稿,每次会话切换都会丢掉焦点、选中,以及用户已经输入的内容。

useLayoutEffect 不是默认选项

useLayoutEffect 默认不用,换成 useEffect 也不算修好,因为二者的差别在时序,而不在代码在做什么。布局测量先回答三个问题——为何在此处发生、为何 CSS 无法表达、为何必须是像素测量;只有回答完,才收进注入的测量 hook 并取得对应领域专家的确认。两类 effect 都不承担状态记录或逻辑绑定,那是 Injection 的职责。

  • 判定依据是用途而不是 hook 名字:这个 effect 为哪次测量或哪次绑定而存在。状态记录或逻辑绑定的 effect 回到 Injection——Provider 或注入 hook;只有通过三问的测量才留下,其结果作为声明式布局参数消费。
  • 不在范围内:两个 effect 的时序差异(@types/react 记载 useLayoutEffect 在 DOM 变更后同步触发、绘制前刷出)是背景事实,不是把其一改写为另一的许可。
  • 例外:任何保留下来的 useLayoutEffect 都要经对应领域专家确认,改动中记录该确认。
  • 修法:用 CSS 表达布局——grid、aspect-ratio、容器查询——或把测量收进注入 hook 并暴露一个值。

布局用 CSS 声明,唯一通过三问的测量住在注入 hook 里:

// Fix 1 — express the layout in CSS and delete the measurement.
// .card { display: grid; grid-template-columns: minmax(0, 1fr) auto; aspect-ratio: 16 / 9; }

// Fix 2 — the approved measurement is injected and returns one value.
function CardBase({ useLayoutMeasurements }: Props) {
  const ref = useRef<HTMLDivElement>(null)
  const { gutterWidth } = useLayoutMeasurements(ref)
  return <div ref={ref} className={s.card} style={{ '--gutter': `${gutterWidth}px` }} />
}

下面前两个 effect 回答的是错的问题——该用哪个 effect——第三个把绑定停在 effect 里:

// A synchronous pixel measurement the design did not need.
useLayoutEffect(() => { setHeight(ref.current.getBoundingClientRect().height) }, [])

// The same measurement with the hook name swapped.
useEffect(() => { setHeight(ref.current.getBoundingClientRect().height) }, [])

// Logic binding parked in an effect instead of Injection.
useEffect(() => { bindShortcut(handler) }, [])

改名并不能解决布局问题,量到的高度从此取决于选了哪个 effect,而快捷键绑定迟一轮渲染生效,没有回到 Injection。

effect 绝不把渲染期可派生的数据推给父层

effect 体内绝不调用经 props 或 slot props 传来的 setter,把本可在渲染期由组件已有的 props / state 决定的数据推回父层。这种推送要多付一轮渲染、让父层首帧拿不到值,并把之后任何一次状态抖动放大成渲染级联。该值在渲染期决定,宿主经渲染期通道取得,或由用户手势在其处理器内更新自己的状态。

  • 判定方式:effect 体内调用了 props 或 slot props 传来的 setter,其入参由当前 props / state 派生,且没有外部事件或订阅回调驱动它。清理函数 return () => setX(undefined) 属于同一违规。
  • review 中要点名的后果:多一轮渲染;父层首帧缺值;依赖数组漏项即 stale;effect → 父 state → 再渲染的级联;子组件生命周期与父层状态纠缠。
  • 不在范围内:响应真实外部事件的 effect——订阅回调、socket 帧、用户手势——不是本形态;事件处理器可以直接更新状态。
  • 例外:默认无例外;任何例外都要经对应领域专家确认。
  • 修法按顺序:状态在哪里就让 UI 在哪里渲染;必须由宿主渲染时,在渲染期把值交出去;跨条目共享的状态移进注入面或声明的 store。

控件在状态所在处渲染,共享的开关在渲染期读取,写回只发生在手势处理器里:

// 1. Render it where the state already lives; no channel is needed.
return <Button onClick={() => setShowOriginal((v) => !v)}>{label}</Button>

// 2. The host renders it: the flag is declared state, read during render
//    and written only from the gesture handler.
const { showOriginal } = useStore()
const { toggleOriginal } = actions
return <Button onClick={toggleOriginal}>{showOriginal ? rendered : source}</Button>

label 与 disabled 都是渲染期可派生的,从 effect 调用父层 setter 让父层渲染两轮、首帧仍然没有头部动作:

useEffect(() => {
  setHeaderAction({ label, disabled, onClick })   // render-derivable data pushed up
  return () => { setHeaderAction(undefined) }
}, [label, disabled, setHeaderAction])

父层为本来第一轮就能拿到的数据渲染两轮,头部迟一帧出现或闪烁,而发布时间由手写的依赖数组决定。

构建与样式各归其主

客户端包的每项构建调整都经该包的 tsdown 插件完成,包内不新开自己的构建分支。样式走 CSS Module,只有图像、GIF 与 SVG 需要内联——且内联到 CSS 而不是 JSX。TSX 里绝不内联大文本,style 对象只放运行期计算的值,因为静态值与多样式切换属于 CSS Module 的 class name。

  • 判定方式:包内新开构建分支而不是走 tsdown 插件;feature 样式里出现字面颜色、组件库或 Tailwind;TSX 里出现 base64 数据或大文本;style 对象承载静态值或样式切换。
  • 不在范围内:运行期计算的值(量到的 gutter、进度百分比)可以进 style,组件局部自定义属性也可以承载布局值。组件规则 正家负责 token、CSS Module 与 class name 规则。
  • 例外:新增构建分支或新的内联形态在落地前于 review 中说明理由,由所属包的规则决定是否保留。
  • 修法:把样式切换移进用 className 切换的 CSS Module class,base64 交给构建链写进 CSS,并从 TSX 模块里删掉大文本常量。

组件切换 class name 与 data 属性,呈现由 CSS Module 负责:

import s from './card.module.css'

// Style variants are class names, not style-object branches.
export function Card({ active, tone, children }: Props) {
  return (
    <div className={clsx(s.card, active && s.active)} data-tone={tone}>
      {children}
    </div>
  )
}

大文本与内联二进制数据进了模块,style 对象还编码了主题无法覆盖的样式切换:

const HUGE = '...several KB of text...'   // large text inline in the module

// A variant switch and an inlined asset both sit in the style object.
<div style={{ color: toneColor, backgroundImage: `url(${AVATAR_DATA_URL})` }} />

包里带上了本该属于 CSS 与资源的文本和二进制数据,diff 里躺着无法阅读的 blob,主题覆盖也再够不到那个颜色。

传 key 就要说明它标识什么

只有当 key 指明元素代表哪个实体或实例时才传它,并把这份身份说清楚——例如 turnId 在一个 session 内唯一。多数列表本就有稳定的业务 id,不需要额外的 key。为消除警告或编码渲染顺序而造的 key,声明的是一份数据并不具备的身份;数组下标从来不是这份身份,因为插入或删除会把后面每一项挪到另一个元素上。

  • 判定方式:条目的身份能否由位置或稳定 id 表达。传 key 时,改动要说明它因何唯一,而可变列表上的 key={index} 不合格。
  • 不在范围内:React 要求列表项带 key 这条规则依然成立;那个 key 就是条目的稳定业务 id,不是它旁边的第二个 key。
  • 例外:无。普通改动无需批准,公共组件的 key 语义要在 review 中说明。
  • 修法:使用实体的稳定 id;两行确实共用一个 id 时,修 id 的来源,而不是往 key 里掺下标。

key 就是 turn 自己的 id,它在 session 内唯一,正是元素所代表的身份:

// turnId is unique inside a session; the id is the identity.
export function TurnList({ turns }: Props) {
  return (
    <ol>
      {turns.map((turn) => <TurnRow key={turn.turnId} turn={turn} />)}
    </ol>
  )
}

下标冒充身份,于是列表一变,后面每一行的身份都跟着变:

// The index stands in for identity.
{items.map((item, index) => (
  <Row key={index} data={item} />
))}

插入或删除之后,后面每一行都会把先前的状态带到新数据上,展开的行或编辑中的内容会出现在错误的条目上。

每层共享代码各有自己的隔离度

功能把诉求吸收在自己的包里;它需要与共享代码保持多大隔离,取决于那段代码属于哪一层共享。要盯的违规形态是把业务专属概念放进共享层,因为此后每个使用方都得理解一个不属于自己的概念,而这个分支很难撤回。

共享代码 功能应保持的隔离度
ui-primitives,目录中的控件 消费目录中的控件。新控件先在功能包内落地,只有出现第二个使用方时才提升进共享目录。
ui-layout 与 shell 布局 消费它;在功能包内通过 wrapper、覆盖样式或加一层来适配。
ui-renderer 及其他渲染机制 业务代码不产生值依赖;只经文档化的注册面扩展——slots、provide/inject 绑定的 hooks、声明的 store。
另一个功能插件 不运行时导入、也不重新导出它的值。UI 经 slots 跨越,行为经注入的服务跨越;共享运行时代码住在窄的静态正家,如 client/store、ui-primitives 或浏览器安全的工具包。
数据对象层(client/connection、session-controller/client、client/store) 经文档化接口读取;功能自己的概念不进入这些对象及其 store。
功能自己的包 不受限——诉求在这里被吸收,包括它自己的行、排序与呈现。
  • 判定方式:diff 是否把业务专属的分支、字段或概念放进了功能自己包以外的任何一层。
  • 不在范围内:功能自己拥有的代码;以及确实属于通用需求、而不是某一个功能需要的共享层改动。
  • 例外:共享层的改动要带上所属领域专家的确认,并回归所有使用方;GUI 改动按 packages/client/AGENTS.md 的最低检查自验。
  • 修法:把诉求吸收进功能包;确属通用需求时,在独立改动中连同 owner 的确认一起提升到共享层。

功能自己拥有行渲染与排序规则,基座组件按原样消费:

// The feature owns its rows; the base is consumed, not edited.
// packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
export function WorkspaceBrowser({ rows, onArchive }: Props) {
  const ordered = [...rows].sort((a, b) => Number(b.archived) - Number(a.archived))
  return (
    <ul>
      {ordered.map((row) => <WorkspaceRow key={row.id} row={row} onArchive={onArchive} />)}
    </ul>
  )
}

基座组件学会某一个功能的排序规则,于是每个使用方都继承了一个不属于自己的概念:

// ui-layout: the base learns one feature's ordering rule.
export function BrowserList({ rows, ...rest }: Props) {
  const ordered = [...rows].sort((a, b) => Number(b.archived) - Number(a.archived))
  return <ul {...rest}>{ordered.map((row) => <Row key={row.id} row={row} />)}</ul>
}

ui-layout 的每个其他使用方从此都要理解只有那一个功能才有的归档概念,撤回该分支意味着把它们全部审一遍。