solid
GitHub提供 OpenClaw 中 Solid 2 UI 的开发指南,涵盖组件编写、重构、审查与测试。明确状态所有权、原生自定义元素及性能优化规则,确保代码符合 API 规范并避免常见陷阱。
触发场景
安装
npx skills add openclaw/openclaw --skill solid -g -y
SKILL.md
Frontmatter
{
"name": "solid",
"description": "Write, refactor, review, or test Solid 2 UI in OpenClaw (Control UI, plugin views, Canvas hosts), including state ownership, native custom elements, overlays, testing, performance, and lessons learned."
}
Solid 2 in OpenClaw
The Control UI and plugin views (extensions/x, extensions/workboard) render with Solid 2 (solid-js, @solidjs/web, @solidjs/signals, compiled by @solidjs/vite-plugin/@solidjs/compiler). Lit and Web Awesome are removed from these surfaces; new code is Solid only. The Canvas hosts also use Solid, but the sandboxed upstream A2UI v0.8 catalog retains its separate @a2ui/lit dependency.
Read ui/AGENTS.md first; its state-ownership rules apply to Solid unchanged. Use the exact installed Solid pins from the owning package.json: the runtime and compiler are 2.0.0-rc.14, and @solidjs/vite-plugin is 3.0.0-next.49. solid-js and @solidjs/* are exempt from the repository's seven-day release-age gate (Peter, 2026-10-11); third-party Solid ecosystem packages remain subject to it.
Rules that keep the UI correct
Before your first edit, read the installed ui/node_modules/solid-js/CHEATSHEET.md, especially its "What changed from 1.x" list. It is the rc.14 API contract, written as a guard against React and Solid 1 priors, the two dominant bug sources. For new code or a Solid audit, then read idioms and lessons; to refactor or review existing code, also use the refactor checklist. Check solid-js/types/client/flow.d.ts and the installed @solidjs/signals / @solidjs/web types when choosing an API. rc.14 has no createAsync: asynchronous values are ordinary computations returning promises or async iterables.
- Domain owners stay plain TypeScript. The Gateway store, capabilities,
lib/sessions/*, caches, persistence, and mutation authority keep synchronous mutate-then-read semantics. Solid 2 writes become visible only after a microtask flush, so authoritative state never lives in a signal. → state and ownership - Components consume projections from
ui/src/lib/reactive/(one per owner and scope, never a second store) anduseApplication()for the application context. Add a thin adapter only with its first production caller; subscribe to ordered events directly. - Host tags and classes survive. A component renders its existing
openclaw-*tag as a plain host element in light DOM; CSS and E2E select it. Never rename a tag or a class. → components - One implementation per registered tag. Native custom elements and Solid callers share one renderer through
defineSolidBridgeinui/src/lib/solid-bridge.ts. Use its returned component in JSX so the host has one lifecycle owner. → components - One renderer per DOM region, and one lifecycle owner per overlay surface. → overlays
- Derive, don't synchronize.
createMemobeforecreateSignal; no copies of owner state; no defensive guards for states the owner already excludes. Prefer smaller code with fewer owners. - Replace controller lifecycles at their owner. Follow the controller-to-Solid recipe, including its worked example. Delete the class and host adapter wiring together; do not wrap a controller in revision signals as the final design.
Component skeleton
An illustrative component (the tag is an example): it renders its host tag, reads an owner through a projection, and derives instead of copying state.
import { createMemo, Show } from "solid-js";
import { useApplication } from "../lib/reactive/context.ts";
import { projectAgents } from "../lib/reactive/domain-capabilities.ts";
export function AgentCount(props: { compact?: boolean }) {
const app = useApplication();
// Reads through to the synchronous owner; disposed with this component's owner.
const agents = projectAgents(app.agents);
// Never destructure props: reads must stay reactive.
const count = createMemo(() => agents.read().agentsList?.agents.length ?? 0);
return (
<openclaw-agent-count class={["agent-count", { "agent-count--compact": props.compact }]}>
<Show when={count() > 0} fallback={<span class="muted">—</span>}>
<span>{count()}</span>
</Show>
</openclaw-agent-count>
);
}
Use jsxImportSource: "@solidjs/web" and its JSX types, not Solid 1's solid-js/web.
Traps that compile but break
on:x,attr:x,bool:x,classList, anduse:are gone in Solid 2; they compile to literal attributes or no-ops (lint flags them).prop:is the only namespace left.- Events are camelCase (
onClick). Dashed custom events need the exact dash, such asonSession-change, or alisten(...)ref factory. - Native event bindings bind once: dispatch through a stable callback that reads the current handler prop. Runtime spreads, including dynamic elements, can update handlers.
- A read right after a write sees the old value until
flush(). Don't write-then-read in handlers; derive. - Reactive reads after the first
awaitin an async memo are untracked, and async memos get noAbortSignal. - Ref callbacks run unowned:
onCleanupinside them does nothing. - ARIA enumerations are typed strings:
aria-pressed={pressed() ? "true" : "false"}, never a boolean. - Solid 1 APIs (
createResource,onMount,mergeProps,Context.Provider) don't carry over: use async computations,onSettled,merge, and the context component itself. - Writing a signal or store from a memo, an effect compute phase, or a component body throws in dev.
untrackdoesn't exempt it (the guard checks the owner); derive instead, or write from a handler,onSettled, or effect apply. - Class strings built with template literals or
.filter(Boolean).join(" ")lose composable reactivity: useclass={["base", { active: active() }]}. createRenderEffectis for render-phase runtime bindings, andflush()in production code usually hides a write-then-read: feature code usescreateEffectand derivations.
Enforced wave-zero guard
pnpm check:control-ui-controllers runs in lint and the changed-file checks.
For production ui/src, it rejects createRenderEffect outside lib/reactive/,
all direct addEventListener calls in TSX, and DOM queries outside the explicit
foreign-DOM owner list. Tests and test helpers are excluded. The native AST scan
counts generic and optional calls as well as named import aliases.
createEffect, untrack, addEventListener, and querySelector(All) also have
per-file shrink-only baselines in config/control-ui-*-baseline.txt. Deleting
calls requires pnpm check:control-ui-controllers --prune; moving an allowance
requires a verified Git rename. Never raise a baseline to admit new debt. See
the refactor checklist for replacements and
the permitted survivors.
More, each with its root cause: lessons learned.
Testing
mountSolid, waitForSolid/flush, renderSolidRef, and the Solid application-context provider live in ui/src/test-helpers/. .test.tsx files are discovered. Keep assertions when you replace a harness. Visual changes need inspected, sanitized before/after screenshots. Follow the assigned campaign's compute budget. → testing
References
| Topic | Read when |
|---|---|
| idioms | choosing a primitive, derivations, props, lifecycle, stores, keys, async, context, diagnostics |
| refactor checklist | refactoring or reviewing existing Solid: measurable smells, replacements, before/after |
| components | writing components, native custom-element hosts, JSX bindings, events, refs, styles |
| state and ownership | touching owners, projections, async reads, lifetimes, parking, chat render lifecycle |
| overlays | menus, popovers, tooltips, dialogs, focus, positioning, the platform floor |
| testing | unit, browser, E2E, WebKit, parity, retention suites |
| performance | startup bundle, lazy loading, render budgets |
| plugins | building a plugin's Control UI view in Solid |
| lessons learned | before a new kind of change; most entries cost a failed CI run to learn |
版本历史
- 8b78b23 当前 2026-10-20 00:21


