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) and useApplication() 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
defineSolidBridge in ui/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.
createMemo before createSignal; 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, and use: 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 as onSession-change, or a listen(...) 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
await in an async memo are untracked, and async memos get no AbortSignal.
- Ref callbacks run unowned:
onCleanup inside 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.
untrack doesn'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: use class={["base", { active: active() }]}.
createRenderEffect is for render-phase runtime bindings, and flush() in production code usually hides a write-then-read: feature code uses createEffect and derivations.
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 |