docs(solid): extend the Solid skill with Solid 2 best practices and make agents read it (#173903)

The idiom wave is replacing Lit-era controllers across 247 files, and agents keep
reaching for React and Solid 1 patterns. This brings the Solid skill up to the
rc.14 contract and makes it mandatory reading.

- SKILL.md: read the installed solid-js CHEATSHEET.md ("What changed from 1.x")
  before the first edit; new traps for owned-scope writes (untrack doesn't
  exempt them), hand-built class strings, createRenderEffect, and flush() in
  production code.
- idioms.md: a primitive-selection table (owner/projection/memo/signal/writable
  derivation/store/createProjection/optimistic/Repeat), lazy and unobserved,
  default-less context, component types and children(), class arrays, array
  refs and directive factories, rc.14 dev diagnostics (STRICT_READ_UNTRACKED,
  LOADING_ON_OUTSIDE_HOLD, owned writes), effect options, and test helpers
  (createRoot, flush, resolve, snapshot).
- references/refactor-checklist.md: measurable smells with replacements and
  allowed exceptions, from a census of main (124 class template literals,
  27 createRenderEffect, 28 production flush() calls, 0 Repeat), plus
  before/after examples.
- AGENTS.md and ui/AGENTS.md: read the skill before writing, reviewing, or
  refactoring any Solid code, not only during the migration.

Sources: the installed solid-js 2.0.0-rc.14 CHEATSHEET.md, the Solid 2.0
MIGRATION.md, and the v2.0.0 beta/RC release notes.
This commit is contained in:
Peter Steinberger 2026-10-15 23:36:49 -07:00 • committed by GitHub
parent 785f2467d8
commit b19d5542f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 229 additions and 17 deletions

View file

@ -5,13 +5,13 @@ description: Write, port, review, or test Solid 2 UI in OpenClaw (Control UI, pl
# Solid 2 in OpenClaw
The Control UI, plugin views (`extensions/x`, `extensions/workboard`), and the Canvas A2UI hosts render with **Solid 2** (`solid-js`, `@solidjs/web`, `@solidjs/signals`, compiled by `@solidjs/vite-plugin`/`@solidjs/compiler`). Lit 3 and Web Awesome are being removed. Until the last Lit file is gone, ported Solid code runs next to unported Lit at every commit.
The Control UI, plugin views (`extensions/x`, `extensions/workboard`), and the Canvas A2UI hosts render with **Solid 2** (`solid-js`, `@solidjs/web`, `@solidjs/signals`, compiled by `@solidjs/vite-plugin`/`@solidjs/compiler`). Web Awesome is removed, and the last Lit interop modules are retiring; new code is Solid only.
Read [ui/AGENTS.md](../../../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
For new code or a Solid audit, read [idioms](references/idioms.md) and [lessons](references/lessons.md). Check `node_modules/solid-js/CHEATSHEET.md`, its `types/client/flow.d.ts`, and the installed `@solidjs/signals` / `@solidjs/web` types when choosing an API. Solid 2 is not Solid 1: rc.14 has no `createAsync`; asynchronous values are ordinary computations returning promises or async iterables.
**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](references/idioms.md) and [lessons](references/lessons.md); to refactor or review existing code, also use the [refactor checklist](references/refactor-checklist.md). 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.
1. **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](references/state-and-ownership.md)
2. **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.
@ -58,6 +58,9 @@ Use `jsxImportSource: "@solidjs/web"` and its JSX types, not Solid 1's `solid-js
- 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](references/lessons.md).
@ -67,15 +70,16 @@ More, each with its root cause: [lessons learned](references/lessons.md).
## References
| Topic | Read when |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| [idioms](references/idioms.md) | derivations, props, lifecycle, local stores, keys, async values, and lint decisions |
| [components](references/components.md) | porting or writing components: Lit → Solid table, JSX bindings, events, refs, styles |
| [state and ownership](references/state-and-ownership.md) | touching owners, projections, async reads, lifetimes, parking, chat render lifecycle |
| [Lit interop](references/lit-interop.md) | a Lit caller uses your component, or your component hosts unported Lit |
| [overlays](references/overlays.md) | menus, popovers, tooltips, dialogs, focus, positioning, the platform floor |
| [testing](references/testing.md) | unit, browser, E2E, WebKit, parity, retention suites |
| [performance](references/performance.md) | startup bundle, lazy loading, render budgets |
| [plugins](references/plugins.md) | building a plugin's Control UI view in Solid |
| [migration](references/migration.md) | porting the remaining Lit, the final sweep, the inventory |
| [lessons learned](references/lessons.md) | before a new kind of change; most entries cost a failed CI run to learn |
| Topic | Read when |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| [idioms](references/idioms.md) | choosing a primitive, derivations, props, lifecycle, stores, keys, async, context, diagnostics |
| [refactor checklist](references/refactor-checklist.md) | refactoring or reviewing existing Solid: measurable smells, replacements, before/after |
| [components](references/components.md) | porting or writing components: Lit → Solid table, JSX bindings, events, refs, styles |
| [state and ownership](references/state-and-ownership.md) | touching owners, projections, async reads, lifetimes, parking, chat render lifecycle |
| [Lit interop](references/lit-interop.md) | a Lit caller uses your component, or your component hosts unported Lit |
| [overlays](references/overlays.md) | menus, popovers, tooltips, dialogs, focus, positioning, the platform floor |
| [testing](references/testing.md) | unit, browser, E2E, WebKit, parity, retention suites |
| [performance](references/performance.md) | startup bundle, lazy loading, render budgets |
| [plugins](references/plugins.md) | building a plugin's Control UI view in Solid |
| [migration](references/migration.md) | porting the remaining Lit, the final sweep, the inventory |
| [lessons learned](references/lessons.md) | before a new kind of change; most entries cost a failed CI run to learn |

View file

@ -59,6 +59,30 @@ listener registrations, and LOC for touched production files. The existing Lit
ratchet reports controller-pattern growth while legacy callers remain; after
the production baseline reaches zero it rejects reintroductions.
## Choose the primitive
Start from the narrowest primitive that expresses the data's owner and lifetime:
| Data | Primitive |
| -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Authoritative domain state (Gateway store, sessions, caches, mutation authority) | Plain TypeScript owner; never a signal. Read it through a projection. |
| An owner's snapshot in a view | `projectOwner`/`projectSource` (`lib/reactive/projection.ts`), narrowed with memos |
| A cheap value derived from props or signals | An accessor `() => …` or a JSX expression |
| An expensive or shared derivation | `createMemo` (with `equals` only for immutable values) |
| Local interaction state (open, hovered, active tab) | `createSignal` |
| A local draft that resets or reconciles from a source | Writable derivation `createSignal(() => source())`, with the reset policy commented |
| Structured or keyed local state | `createStore` with draft setters; `reconcile(rows, "id")` for keyed lists |
| A keyed collection derived from async or owner data | `createProjection(fn, initial, { key: "id" })` |
| An expected value shown before a mutation settles | `createOptimistic`/`createOptimisticStore` inside `action` |
| Count-only repetition | `<Repeat count={n}>` |
`createMemo(fn, { lazy: true })` defers the first computation until something
reads it and disposes it when nothing observes it; `createSignal(v, { unobserved })`
fires when the last subscriber leaves. Reach for them in new adapters that must
release upstream work with their last reader. Keep the existing `projectSource`
contract (read-through, revision equality, scope replacement) rather than
reimplementing it piecemeal.
## Derive values; effects perform work
A component body runs once. JSX bindings, memos, and effect compute phases track
@ -304,6 +328,74 @@ admission, rollback policy, target capture, or stale-result authority. Keep the
owner's visible error/retry outcome and catch async rendering errors with
`Errored` rather than an invented `.error` field on a read accessor.
## Context, components, and children
`createContext<T>()` without a default is the canonical form: `useContext`
returns `T` and throws `ContextNotFoundError` outside a provider, so don't add a
`useX()` wrapper whose only job is to rethrow. Use a default only for primitive
fallbacks (theme, locale). The context itself is the provider:
`<ThemeContext value={theme()}>`. App-wide state is not context: in OpenClaw it
lives in plain TypeScript owners reached through `useApplication()`.
Type components with `Component<P>`, `VoidComponent<P>`, `ParentComponent<P>`,
or `FlowComponent<P, C>`; renderer-neutral children are `Element` from
`solid-js`. Resolve children once with `children(() => props.children)` when a
component must inspect or reuse them; otherwise render `props.children`
directly.
## Classes, attributes, and refs
Compose classes with the array/object form,
`class={["badge", props.class, { "badge--muted": muted() }]}`. Never build
class strings with template literals, `.filter(Boolean).join(" ")`, or
concatenation: the array form keeps each condition reactive and composable.
Built-in attributes are lowercase (`tabindex`, not `tabIndex`); boolean
attributes are presence/absence (`muted={false}` removes it).
Refs are functions. Compose several with an array, `ref={[autofocus, tooltip(opts)]}`;
a bare `let el` inside that array is never assigned (that rewrite only works for
a single `ref`). Reusable DOM behavior is a two-phase directive factory: create
primitives in the owned setup phase, return an unowned callback that only
captures the node and writes to it.
## Dev diagnostics are bug reports
Treat these rc.14 dev warnings as real defects, not noise:
- **Top-level reactive read in a component body.** The body runs once. Read in
JSX, a memo, or an accessor.
- **Write under an owned scope.** Writing a signal or store from a memo, an
effect compute phase, or a component body throws in dev. `untrack` doesn't
help, because the guard checks the owner, not tracking. Move the write to an
event handler, `onSettled`, or effect apply, or derive instead of writing.
- **`STRICT_READ_UNTRACKED`.** A value read in an effect apply callback or at
the top of a flow callback (`For`, `Show`, `Match`) is frozen. Read it in the
compute phase, JSX, or a memo.
- **`LOADING_ON_OUTSIDE_HOLD`.** The same pending data is read outside the
`Loading` boundary, so its fallback can never show. Move the read under the
boundary.
`onSettled` also works inside an event handler: it defers follow-up work
(focus, measurement) until the transition that handler started has settled.
## Effects: the rare cases
`createEffect(compute, apply)` is the only effect form. Its options cover the
common edge cases without extra primitives: `{ defer: true }` skips the initial
run, and the object form `createEffect(compute, { effect, error })` routes
async failures to `error`, replacing Solid 1's `onError`/`catchError`.
`createRenderEffect` exists for render-phase bindings like the runtime's own;
feature code uses `createEffect`. `createTrackedEffect` and `createReaction` are
escape hatches for library internals, not for views.
## Tests
Reactive primitives need an owner: wrap ad hoc test code in
`createRoot((dispose) => …)` and dispose it. Call `flush()` before asserting on
a value you just wrote. For an async computation, `await resolve(() => value())`
waits for it to settle instead of polling. Assert snapshots of stores with
`snapshot(store)`, not the proxy.
## Audit and lint without fighting the contract
The V2 configuration lives in `.oxlintrc.json` with the plugin isolated under

View file

@ -0,0 +1,116 @@
# Refactor checklist: idiomatic Solid 2, less code
Use this when refactoring or reviewing existing Solid code. The migration left
working code that still carries Lit-era shapes: controller classes, effects that
copy state, imperative listeners, and hand-built class strings. Each row below
is a smell with a measurable count, the idiomatic replacement, and the
exception that justifies keeping it. Measure the touched production files before
and after, and report the counts in the PR. Net production LOC must go down.
The installed `solid-js/CHEATSHEET.md` (rc.14) is the API contract. Its
"What changed from 1.x" list is required reading before the first edit: Solid 2
is neither React nor Solid 1, and both priors produce plausible-looking bugs.
## Measure
Run from the repository root; scope paths to your area:
```sh
area=ui/src/pages/sessions # your scope
prod() { git grep -nE "$1" -- "$area" ':!*.test.*' ':!**/test-helpers/**' ':!**/e2e/**'; }
prod 'hostConnected|hostDisconnected|requestUpdate\(|class [A-Za-z]+Controller' | wc -l
prod 'createEffect\(' | wc -l
prod 'createRenderEffect\(' | wc -l
prod 'untrack\(' | wc -l
prod 'addEventListener\(' | wc -l
prod 'querySelector(All)?\(' | wc -l
prod 'class=\{`|filter\(Boolean\)\.join\(" "\)' | wc -l
prod '[^a-zA-Z_.]flush\(\)' | wc -l
prod 'runWithOwner\(|getOwner\(' | wc -l
```
## Smells and replacements
| Smell | Replace with | Keep only when |
| ------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Lit controller (`hostConnected`, `requestUpdate`, `class …Controller` owned by a view) | `createThing(deps)` per the [controller recipe](idioms.md#replace-lit-era-controllers): signals, memos, `createStore`, owner-scoped setup with `onSettled`/`onCleanup` | It is a plain TypeScript domain owner with synchronous non-UI state and no host/update semantics. Name it as an owner, not a controller. |
| An effect that writes a signal or store from another reactive value | `createMemo`, or a writable derivation `createSignal(() => source())` with a stated reset policy | Never. An owned-scope write throws in dev; `untrack` does not exempt it, and `ownedWrite: true` hides the design problem. |
| A conversion that raises the `createEffect` count | Setup/teardown in the owning function, derived values in memos, event updates in handlers | The effect synchronizes with a real external system (DOM measurement, imperative widget, transport). Comment why. |
| `createRenderEffect` in feature code | `createEffect(compute, apply)` | The binding must run during render, like the runtime's own attribute bindings. That's rare outside primitives. |
| `untrack` silencing a warning or freezing props | Model the dependency: read in JSX, a memo, or the effect compute phase | A deliberate snapshot of lifetime-stable identity. Comment the reason. |
| `addEventListener` without paired owner cleanup | A JSX handler (`onClick={(e) => props.onX(e)}`), or `lib/reactive/listener.ts` returning teardown from `onSettled` / effect apply | Native capture or `passive` options, or `window`/`document` targets, through the owned helper. |
| `querySelector` to find your own DOM | A `ref` callback or a two-phase ref directive factory | Querying a deliberate island (xterm, noVNC, PDF, canvas, markdown reconciler) owned by that island's code. |
| Class strings: template literals, `.filter(Boolean).join(" ")`, string concatenation | `class={["base", props.class, { active: active() }]}` | Never. |
| `flush()` in production code | Derive the value instead of write-then-read; let microtask batching run | An imperative integration that must observe committed DOM synchronously. Name the integration and keep it at the owner. |
| `runWithOwner`/`getOwner` capturing owners for nested mounts | Create primitives in the component or factory body; remove once Lit interop is gone | A library adapter mounting Solid content inside a foreign DOM island. |
| Manual `subscribe`/notify loops in a view | `projectOwner`/`projectSource` from `lib/reactive/projection.ts` | Event APIs whose ordering, replay, or completion contract a latest-value read would lose. Those stay direct subscriptions. |
| `For each={Array.from({ length: n })}` or index-only maps | `<Repeat count={n}>` (plain numeric index, no diffing) | Real entities need keys: use `For keyed={(x) => x.id}`. |
| A `useX()` context wrapper that throws on a missing provider | `createContext<T>()` without a default (throws `ContextNotFoundError` itself), read with `useContext` | The wrapper adds real behavior beyond the throw. |
| `Provider` components, `mergeProps`, `splitProps`, `onMount`, `createResource`, `Index`, `classList`, `use:` | `<Context value>`, `merge`, `omit`, `onSettled`, async computation + `<Loading>`, `For keyed={false}`, `class` arrays/objects, `ref={factory()}` | Never: these are Solid 1 and either fail to compile or silently no-op. |
| Loading flags in local signals around an async read | An async `createMemo`/`createProjection` under `<Loading>`; `isPending(() => x())` for in-flight changes | The flag is an owner's mutation state (saving/admitted/uncertain), which stays at the owner. |
| Hand-rolled keyed merge of server lists into local state | `createProjection(async () => owner.list(), [], { key: "id" })`, or `reconcile(rows, "id")` inside a store draft | The list is an owner snapshot; consume it through a projection instead. |
## Order of work
1. Read the module, its owner, its callers, and its tests. Name the contract each
piece serves before deleting anything.
2. Convert the state model first (controller → `createThing`, copies → memos),
then lifecycle, then events and refs, then markup cleanups like class arrays.
3. Delete the old class, adapter wiring, and test-only exports in the same PR.
Tests assert public behavior and cleanup, not internals.
4. Measure again. If LOC or the effect count went up, the conversion isn't done.
## Before and after
A view controller with manual updates:
```ts
// Before: a Lit-era controller owned by one view.
class FilterController {
query = "";
constructor(private host: { requestUpdate(): void }) {}
hostConnected() {
window.addEventListener("keydown", this.onKey);
}
hostDisconnected() {
window.removeEventListener("keydown", this.onKey);
}
setQuery(value: string) {
this.query = value;
this.host.requestUpdate();
}
private onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") this.setQuery("");
};
}
```
```ts
// After: signals drive updates; setup and teardown live together.
function createFilter() {
const [query, setQuery] = createSignal("");
onSettled(() =>
listen(window, "keydown", (event) => {
if (event instanceof KeyboardEvent && event.key === "Escape") setQuery("");
}),
);
return { query, setQuery };
}
```
A copied value becomes a derivation, and a hand-built class string becomes the
array form:
```tsx
// Before
const [label, setLabel] = createSignal("");
createEffect(
() => props.count,
(count) => setLabel(t("items", { count })),
);
return <span class={`badge ${props.muted ? "badge--muted" : ""}`}>{label()}</span>;
// After
const label = () => t("items", { count: props.count });
return <span class={["badge", { "badge--muted": props.muted }]}>{label()}</span>;
```

View file

@ -118,7 +118,7 @@ Read matching guides in full; commands and details stay with them.
- **Plugins/SDK:** [plugins](extensions/AGENTS.md), [loader](src/plugins/AGENTS.md), [SDK](src/plugin-sdk/AGENTS.md) (owns public boundary expansion, including outside callers).
- **Channels/message actions:** [channel boundary](src/channels/AGENTS.md), [channel responsibilities](docs/plugins/sdk-channel-plugins.md).
- **Agent tools, prompts, admission, lifecycle:** [agents](src/agents/AGENTS.md), [Gateway](src/gateway/AGENTS.md).
- **Control UI:** [UI guide](ui/AGENTS.md), including state shared with other Gateway clients.
- **Control UI:** [UI guide](ui/AGENTS.md), including state shared with other Gateway clients. Before writing, reviewing, or refactoring Solid code (Control UI, plugin views, Canvas hosts), read the [Solid skill](.agents/skills/solid/SKILL.md) and the installed `solid-js` cheatsheet it points to.
- **Storage:** [database schemas](docs/reference/database-schemas.md) and subpages; read approval checkpoint before schema, transaction, retention, recovery changes.
- **Config migration:** [Doctor transforms](docs/gateway/doctor/config-migrations.md), never new runtime compatibility readers.
- **Audit/identity:** [audit doctrine](docs/gateway/audit.md); provenance = opt-in, never authorization; changes to collection, reader scope, retained fields, bounds, contracts need approval.

View file

@ -2,9 +2,9 @@
UI ownership rules here; repo-global architecture, proof, Git rules in root `AGENTS.md`.
## Solid migration
## Solid
- Lit 3/Web Awesome ports + Solid 2 components/projections: follow [Solid skill](../.agents/skills/solid/SKILL.md) + assigned migration order for conversion, lifecycle, interop, proof. State ownership below still applies.
- Before writing, reviewing, or refactoring any Solid code here, read the [Solid skill](../.agents/skills/solid/SKILL.md) in full, the installed `node_modules/solid-js/CHEATSHEET.md` it points to, and the references it routes you to; for existing code, the [refactor checklist](../.agents/skills/solid/references/refactor-checklist.md). State ownership below still applies.
- Final Lit sweep: remove Lit-specific guidance.
Solid route modules expose `renderSolid(props)` from the existing lazy page import.