solid2-agent-kit 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/bin/solid2-kit.mjs +368 -0
- package/files/shared/agents-section.md +6 -0
- package/files/shared/rules-body.md +222 -0
- package/files/skills/solid-2/SKILL.md +731 -0
- package/files/skills/solid-2/references/official-docs.md +154 -0
- package/package.json +42 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: solid-2
|
|
3
|
+
description: Write correct Solid 2.0 code (components, signals, stores, effects, async, JSX). Use whenever creating or editing .tsx files, reviewing UI code, or answering questions about Solid in this repo. Solid 2.0 is NOT React and NOT Solid 1.x — this skill prevents both classes of mistakes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Writing Solid 2.0
|
|
7
|
+
|
|
8
|
+
This project uses Solid 2.0 (`solid-js` 2.x) with `@solidjs/web`. The hard rules are
|
|
9
|
+
installed by solid2-agent-kit as an always-applied rules file (`.cursor/rules/solid-2.mdc`
|
|
10
|
+
for Cursor; a managed block in `CLAUDE.md` for Claude Code). This skill adds the mental
|
|
11
|
+
model, decision tables, and canonical patterns. When an API is in doubt, verify against the
|
|
12
|
+
official docs — see [Checking the official docs](#checking-the-official-docs).
|
|
13
|
+
|
|
14
|
+
## The one difference everything follows from
|
|
15
|
+
|
|
16
|
+
React re-runs component functions to compute the next UI. **Solid runs a component function
|
|
17
|
+
once, at mount.** After that, signals and stores notify only the computations and JSX
|
|
18
|
+
expressions that read them. There is no re-render, no reconciliation of component output,
|
|
19
|
+
no snapshot props, no stale closures, no dependency arrays.
|
|
20
|
+
|
|
21
|
+
Practical consequences:
|
|
22
|
+
|
|
23
|
+
- Values must travel as **accessors (functions)**, not as extracted values. Extracting a
|
|
24
|
+
value at component-body top level freezes it forever.
|
|
25
|
+
- The component body is **untracked**. Tracking scopes are: JSX expressions, `createMemo`
|
|
26
|
+
compute, `createEffect` compute (first argument), and boundary children.
|
|
27
|
+
- Event handlers run untracked: they always read current values and never subscribe.
|
|
28
|
+
- Object identity is stable: handlers, context values, and props objects are created once.
|
|
29
|
+
`useCallback` / `useMemo`-for-identity / `React.memo` have no purpose here.
|
|
30
|
+
|
|
31
|
+
## Decision tables
|
|
32
|
+
|
|
33
|
+
### Where does state live?
|
|
34
|
+
|
|
35
|
+
| Situation | Use |
|
|
36
|
+
|---|---|
|
|
37
|
+
| Scalar local state (count, open/closed, text input) | `createSignal(value)` in the component |
|
|
38
|
+
| Object/array state where parts update independently (forms, lists, nested data) | `createStore(value)` — property-level subscriptions |
|
|
39
|
+
| Object that is replaced wholesale (fetch response, selected item, `User \| null`) | `createSignal(objectValue)` |
|
|
40
|
+
| Value derived from other reactive values | plain function `() => ...` (never a signal synced by an effect) |
|
|
41
|
+
| Expensive derivation, multiple consumers, or equality boundary needed | `createMemo(() => ...)` |
|
|
42
|
+
| Follows a prop but user can locally override; prop change resets | writable derivation: `createSignal(() => props.value)` / `createStore(() => props.value, fallback)` |
|
|
43
|
+
| Shared by a subtree, independent per provider instance | signal/store created inside a provider component, passed via context |
|
|
44
|
+
| Truly app-wide singleton (theme, session, locale) | module-level signal/store (beware SSR: module state is shared across requests) |
|
|
45
|
+
| Tentative value during a mutation | `createOptimistic` / `createOptimisticStore` + `action` |
|
|
46
|
+
| Values pushed by an external subscription (websocket, reactive client) | async iterable returned from a memo (or function-form `createStore` for keyed reconciliation) — never `{ data, error }` signal pairs |
|
|
47
|
+
|
|
48
|
+
### Should this be an effect?
|
|
49
|
+
|
|
50
|
+
Most React `useEffect` code should NOT become `createEffect`:
|
|
51
|
+
|
|
52
|
+
| React habit | Solid 2 |
|
|
53
|
+
|---|---|
|
|
54
|
+
| Compute a rendered value in an effect | plain function or `createMemo` |
|
|
55
|
+
| Fetch data in an effect, copy into state | `createMemo(async () => ...)` + `<Loading>` |
|
|
56
|
+
| React to a user interaction | do the work in the event handler or an `action` |
|
|
57
|
+
| Sync one state into another | delete the second state; derive it |
|
|
58
|
+
| Push a settled reactive value into a non-Solid system (DOM API, third-party widget, analytics, subscription) | `createEffect(compute, apply)` — this is the only real use |
|
|
59
|
+
| One-time setup after mount (`onMount`) | `onSettled(() => { ...; return cleanup })` |
|
|
60
|
+
| Measure DOM / observe size after paint | ref **directive factory** + `onSettled` (writes a *new* input; not an effect that copies state) |
|
|
61
|
+
|
|
62
|
+
### Composition, code-splitting, SSR
|
|
63
|
+
|
|
64
|
+
| Situation | Use |
|
|
65
|
+
|---|---|
|
|
66
|
+
| Wrapper that only renders its children | `{props.children}` — no helper |
|
|
67
|
+
| Inspect, count, or iterate children | `children(() => props.children)` then `.toArray()` |
|
|
68
|
+
| Code-split a component | `lazy(() => import("./X"))` read under `<Loading>` |
|
|
69
|
+
| Named export from a lazy module | `lazy(() => import("./pages"), { export: "About" })` |
|
|
70
|
+
| Pick a component/tag from reactive state | `dynamic(() => ...)` from `@solidjs/web` (stable identity) |
|
|
71
|
+
| Browser-only widget (charts, maps, `window`) | `clientOnly(() => import("./Chart"))` from `@solidjs/web` |
|
|
72
|
+
| Server vs browser branch | `isServer` / `isDev` from `@solidjs/web` (build-time constants), not `typeof window` |
|
|
73
|
+
| SSR-stable `id` / `for` / `aria-*` pairing | `createUniqueId()` |
|
|
74
|
+
|
|
75
|
+
## Canonical patterns
|
|
76
|
+
|
|
77
|
+
### Component with local state and derived value
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import { createSignal } from 'solid-js';
|
|
81
|
+
|
|
82
|
+
function Counter() {
|
|
83
|
+
const [count, setCount] = createSignal(0);
|
|
84
|
+
const doubled = () => count() * 2; // derivation: a function, not a memo, not an effect
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<button onClick={() => setCount((c) => c + 1)}>
|
|
88
|
+
{count()} x 2 = {doubled()}
|
|
89
|
+
</button>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Props: read late, never destructure
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
// CORRECT — reads happen inside JSX (a tracking scope)
|
|
98
|
+
function Greeting(props: { name: string; punctuation?: string }) {
|
|
99
|
+
return <p>Hello, {props.name}{props.punctuation ?? '.'}</p>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// WRONG — reads the getter once at setup; never updates again
|
|
103
|
+
function Greeting({ name }: { name: string }) { /* ... */ }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
To give a derived prop a local name, name the *function* (or `createMemo` if expensive):
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
const full = () => `${props.first} ${props.last}`;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
To merge defaults or split props reactively use `merge` / `omit` from `solid-js`
|
|
113
|
+
(replacements for Solid 1.x `mergeProps` / `splitProps`). `merge` is like
|
|
114
|
+
`Object.assign`: an explicit `undefined` **overrides** the previous source.
|
|
115
|
+
Omitted keys still fall through, so `merge({ type: "button" }, props)` is the
|
|
116
|
+
usual defaults pattern. That is not Solid 1 `mergeProps`, which ignored
|
|
117
|
+
`undefined`. Forwarding leftover props: `const rest = omit(props, "label")` then
|
|
118
|
+
`<input {...rest} />`. `const rest = { ...props }` compiles and then never updates.
|
|
119
|
+
|
|
120
|
+
### Store updates (draft mutation)
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
import { createStore } from 'solid-js';
|
|
124
|
+
|
|
125
|
+
const [profile, setProfile] = createStore({ name: 'Ada', role: 'Engineer' });
|
|
126
|
+
|
|
127
|
+
setProfile((draft) => {
|
|
128
|
+
draft.name = 'Grace'; // only readers of .name re-run
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
**External collections enter stores through reconciliation, never wholesale assignment.**
|
|
133
|
+
`draft.todos = serverTodos` renders correctly but replaces every object identity — all
|
|
134
|
+
subscribers under the path re-run and row identity is lost. In order of preference:
|
|
135
|
+
|
|
136
|
+
1. Function-form `createStore(() => source, fallback)` or `createProjection` — reconcile
|
|
137
|
+
automatically (keyed by `"id"`), surviving items keep proxy identity.
|
|
138
|
+
2. Manual merge: `setTodos(reconcile(fresh, 'id'))`, or on a nested slot
|
|
139
|
+
`setState((draft) => { reconcile(fresh, 'id')(draft.todos); })`. Positional data
|
|
140
|
+
(fixed-shape dashboards): `reconcile(next, null)`.
|
|
141
|
+
|
|
142
|
+
Plain non-reactive copy for logging/serialization/`structuredClone`: `snapshot(store)`.
|
|
143
|
+
Subscribe an effect compute to every nested change: `deep(store)`.
|
|
144
|
+
|
|
145
|
+
### Children: pass through, or resolve with `children()`
|
|
146
|
+
|
|
147
|
+
Most wrappers just render `props.children`. Use the `children` helper only when
|
|
148
|
+
the component must **inspect or iterate** them. It returns an accessor with
|
|
149
|
+
`.toArray()`. The helper call belongs in the component body; the read of
|
|
150
|
+
`props.children` is inside the accessor (a tracking scope), so this is not a
|
|
151
|
+
setup-time snapshot. Do not assign `const kids = props.children` and do not
|
|
152
|
+
treat resolved children as a reactive data list for `<For>`:
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
import { children, type ParentProps } from 'solid-js';
|
|
156
|
+
|
|
157
|
+
function Stack(props: ParentProps) {
|
|
158
|
+
const resolved = children(() => props.children);
|
|
159
|
+
return <div class="stack">{resolved.toArray()}</div>;
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Code-splitting: `lazy` + `<Loading>`
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
166
|
+
import { lazy, Loading } from 'solid-js';
|
|
167
|
+
|
|
168
|
+
const Profile = lazy(() => import('./Profile'));
|
|
169
|
+
const About = lazy(() => import('./pages'), { export: 'About' });
|
|
170
|
+
|
|
171
|
+
<Loading fallback={<Spinner />}>
|
|
172
|
+
<Profile id="42" />
|
|
173
|
+
</Loading>
|
|
174
|
+
|
|
175
|
+
<button type="button" onMouseEnter={() => Profile.preload()}>Open profile</button>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Never `React.lazy` / `<Suspense>`. The lazy component suspends through
|
|
179
|
+
`<Loading>` on first render; `.preload()` starts the import early. Named
|
|
180
|
+
exports must use `{ export: "About" }` — a `.then((m) => ({ default: m.About }))`
|
|
181
|
+
wrapper hydrates incorrectly (the name is not a call-site literal).
|
|
182
|
+
|
|
183
|
+
### Dynamic component: `dynamic()` (canonical)
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
import { createSignal, type Component } from 'solid-js';
|
|
187
|
+
import { dynamic } from '@solidjs/web';
|
|
188
|
+
|
|
189
|
+
const Compact: Component<{ value: string }> = (props) => <span>{props.value}</span>;
|
|
190
|
+
const Detailed: Component<{ value: string }> = (props) => <strong>{props.value}</strong>;
|
|
191
|
+
|
|
192
|
+
const [detailed, setDetailed] = createSignal(false);
|
|
193
|
+
const Result = dynamic(() => (detailed() ? Detailed : Compact));
|
|
194
|
+
|
|
195
|
+
<Result value="Current result" />
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`dynamic()` returns a **stable** component whose source can be a component, an
|
|
199
|
+
intrinsic tag name, a promise, or empty. Prefer it over swapping a component
|
|
200
|
+
variable in JSX. `<Dynamic>` is the JSX spelling of the same primitive.
|
|
201
|
+
|
|
202
|
+
### Two-phase effect (imperative boundary only)
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
import { createEffect } from 'solid-js';
|
|
206
|
+
|
|
207
|
+
createEffect(
|
|
208
|
+
() => props.roomId, // compute: ALL reactive reads here; return value feeds apply
|
|
209
|
+
(roomId) => { // apply: untracked imperative work
|
|
210
|
+
const connection = chat.connect(roomId);
|
|
211
|
+
return () => connection.close(); // cleanup before next run / on disposal
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Reads inside `apply` do not track. Extract every needed reactive value in `compute`
|
|
217
|
+
(e.g. `() => ({ name: user.name, role: user.role })`). Passing a store proxy into `apply`
|
|
218
|
+
and reading `user.name` there *runs once* and never retriggers. Compute-phase errors can be
|
|
219
|
+
intercepted with the bundle form `createEffect(compute, { effect, error })`.
|
|
220
|
+
|
|
221
|
+
### Async data
|
|
222
|
+
|
|
223
|
+
```tsx
|
|
224
|
+
import { Errored, For, Loading, createMemo, createSignal } from 'solid-js';
|
|
225
|
+
|
|
226
|
+
const [query, setQuery] = createSignal('');
|
|
227
|
+
const results = createMemo(async () => {
|
|
228
|
+
const value = query().trim();
|
|
229
|
+
if (!value) return [];
|
|
230
|
+
return api.search(value);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<Errored fallback={(error) => <p>{String(error())}</p>}>
|
|
235
|
+
<Loading fallback={<p>Searching...</p>}>
|
|
236
|
+
<ul><For each={results()}>{(r) => <li>{r.title}</li>}</For></ul>
|
|
237
|
+
</Loading>
|
|
238
|
+
</Errored>
|
|
239
|
+
);
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
- The `<Loading>` boundary must be an owner ancestor of the **read** (`results()`), not of
|
|
243
|
+
where the memo was created, and not of page chrome (header/nav) that should stay mounted.
|
|
244
|
+
Boundary placement is purely a UX decision — it does not change when fetches start
|
|
245
|
+
(no waterfalls: nested components set up and fetch in parallel). After first paint the
|
|
246
|
+
boundary keeps settled content during refetch; `isPending(() => results())` is the
|
|
247
|
+
indicator. Use `on={query()}` (the **value**, not the accessor `on={query}`) only when
|
|
248
|
+
that identity should put the fallback back on screen.
|
|
249
|
+
- Read every reactive input **before the first `await`** in an async computation. A read
|
|
250
|
+
after `await` does not subscribe; production can sit pending with no retry.
|
|
251
|
+
- `<Errored>` function fallbacks receive an error **accessor** and a `reset` callback:
|
|
252
|
+
`fallback={(error, reset) => ...}`.
|
|
253
|
+
- Refetch: `refresh(results)`. In-flight indicator: `isPending(() => results())`.
|
|
254
|
+
Freshest in-flight value for imperative code: `latest(results)`.
|
|
255
|
+
- Coordinate reveal order of sibling `<Loading>` boundaries with `<Reveal order="sequential" | "together" | "natural">`.
|
|
256
|
+
|
|
257
|
+
**Never hand-roll these states.** No `[loading, setLoading]` signal, no
|
|
258
|
+
`data() === undefined` branch, no `{ data, error }` signal pair — those are React/Solid 1.x
|
|
259
|
+
reflexes that bypass the async model and `solid2-kit check` flags them. If a value is async,
|
|
260
|
+
make it an async computation and read it under boundaries. Refetching is `refresh(source)`
|
|
261
|
+
(typically as the last step of a mutation `action`, or a reload button) — never a
|
|
262
|
+
counter/version signal read inside the computation to force re-runs; input-driven refetch is
|
|
263
|
+
already automatic.
|
|
264
|
+
|
|
265
|
+
**Normalize thrown values once.** Thrown values are `unknown` in JavaScript, so define one
|
|
266
|
+
shared `ErrorFallback` component per app — extract the message via `instanceof Error`, wire
|
|
267
|
+
`reset` to a Retry affordance — and pass it to every `<Errored fallback>`, instead of
|
|
268
|
+
repeating `String(error())` at each boundary:
|
|
269
|
+
|
|
270
|
+
```tsx
|
|
271
|
+
<Errored fallback={(error, reset) => <ErrorFallback error={error} reset={reset} />}>
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Streams and subscriptions (websockets, reactive clients)
|
|
275
|
+
|
|
276
|
+
Push-based sources integrate by returning an **async iterable** from a computation — reads
|
|
277
|
+
then suspend to `<Loading>` until the first value, later pushes update the settled value,
|
|
278
|
+
and thrown errors reach `<Errored>`. Integration layers should return a plain accessor, not
|
|
279
|
+
`{ data, error }` signals:
|
|
280
|
+
|
|
281
|
+
```tsx
|
|
282
|
+
import { createMemo, onCleanup } from 'solid-js';
|
|
283
|
+
|
|
284
|
+
function createSubscriptionQuery<T>(
|
|
285
|
+
subscribe: (next: (value: T) => void, fail: (error: unknown) => void) => () => void,
|
|
286
|
+
) {
|
|
287
|
+
const queue: T[] = [];
|
|
288
|
+
let failure: unknown;
|
|
289
|
+
let wake = () => {};
|
|
290
|
+
// Component-owned setup: unsubscribe is tied to the caller's owner.
|
|
291
|
+
onCleanup(subscribe(
|
|
292
|
+
(value) => { queue.push(value); wake(); },
|
|
293
|
+
(error) => { failure = error; wake(); },
|
|
294
|
+
));
|
|
295
|
+
|
|
296
|
+
return createMemo(() => (async function* () {
|
|
297
|
+
while (true) {
|
|
298
|
+
if (failure !== undefined) throw failure;
|
|
299
|
+
if (queue.length > 0) { yield queue.shift() as T; continue; }
|
|
300
|
+
await new Promise<void>((resolve) => { wake = resolve; });
|
|
301
|
+
}
|
|
302
|
+
})());
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### Mutations: `action` + optimistic state
|
|
307
|
+
|
|
308
|
+
```tsx
|
|
309
|
+
import { action, createOptimisticStore, refresh } from 'solid-js';
|
|
310
|
+
|
|
311
|
+
const [todos, setTodos] = createOptimisticStore(fetchTodos, []);
|
|
312
|
+
|
|
313
|
+
const addTodo = action(function* (todo: Todo) {
|
|
314
|
+
setTodos((draft) => { draft.push(todo); }); // optimistic, auto-reverts on failure
|
|
315
|
+
yield saveTodo(todo); // yield promises to stay in the transaction
|
|
316
|
+
refresh(todos);
|
|
317
|
+
});
|
|
318
|
+
// invoke from an event handler: onClick={() => void addTodo(newTodo)}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
Ordinary signal/store writes inside an action are held until it settles. Never call
|
|
322
|
+
`flush()` inside an action. Invoke actions from handlers, not from component/computation bodies.
|
|
323
|
+
|
|
324
|
+
**`yield`, not a bare `await`, is the transaction boundary.** `await api.save()` then
|
|
325
|
+
`setTodos(...)` *runs*, but the write commits immediately and optimistic rollback is lost.
|
|
326
|
+
Either `yield saveTodo(todo)` or, when you need a typed result, `const saved = await api.save(); yield; setTodos(...)`.
|
|
327
|
+
|
|
328
|
+
### Lists: `<For>` child signatures per keying mode
|
|
329
|
+
|
|
330
|
+
```tsx
|
|
331
|
+
// Default (keyed by item reference): item is the RAW value, index is an accessor
|
|
332
|
+
<For each={todos()}>{(todo, index) => <Row todo={todo} n={index()} />}</For>
|
|
333
|
+
|
|
334
|
+
// Key function: BOTH are accessors
|
|
335
|
+
<For each={todos()} keyed={(todo) => todo.id}>{(todo) => <Row todo={todo()} />}</For>
|
|
336
|
+
|
|
337
|
+
// Positional (Solid 1.x <Index>): item is an accessor, index is a plain number
|
|
338
|
+
<For each={cells()} keyed={false}>{(cell, i) => <Cell value={cell()} at={i} />}</For>
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Choosing the keying mode:
|
|
342
|
+
|
|
343
|
+
| Where the rows come from | Mode |
|
|
344
|
+
|---|---|
|
|
345
|
+
| Server/refetched data — fetch results, subscription payloads (fresh object references on every update) | **key function on a stable id** (`keyed={(item) => item.id}`) — reference keying would recreate every row on each update |
|
|
346
|
+
| Local array whose item identities are stable (e.g. store rows, static lists) | default (item reference) |
|
|
347
|
+
| Fixed positions where only contents change | `keyed={false}` |
|
|
348
|
+
|
|
349
|
+
Never `{todos().map((t) => <Row todo={t} />)}` and never `<For each={todos().map(...)}>`.
|
|
350
|
+
The `.map` form *renders*; it just rebuilds every row. `children().toArray().map` is
|
|
351
|
+
fine — those nodes are already resolved.
|
|
352
|
+
|
|
353
|
+
`<Repeat from={start()} count={20}>{(index) => ...}</Repeat>` renders by absolute index with
|
|
354
|
+
no array diffing — use for fixed slot counts and virtual scrolling windows.
|
|
355
|
+
|
|
356
|
+
### Conditionals
|
|
357
|
+
|
|
358
|
+
Ternaries work and are reactive, but **when the truthy branch reads the tested value, use
|
|
359
|
+
`<Show>`**: its function child receives a narrowed accessor, so no non-null assertion is
|
|
360
|
+
needed. Never write `value()!` to narrow by hand (`solid2-kit check` flags it):
|
|
361
|
+
|
|
362
|
+
```tsx
|
|
363
|
+
// WRONG — hand narrowing with a non-null assertion
|
|
364
|
+
{error() ? <p>{error()!.message}</p> : <TaskList />}
|
|
365
|
+
|
|
366
|
+
// CORRECT — Show narrows; fallback holds the other branch
|
|
367
|
+
<Show when={error()} fallback={<TaskList />}>
|
|
368
|
+
{(err) => <p>{err().message}</p>}
|
|
369
|
+
</Show>
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
<Show when={user()} fallback={<SignIn />}>
|
|
374
|
+
{(currentUser) => <Profile user={currentUser()} />} {/* narrowed ACCESSOR */}
|
|
375
|
+
</Show>
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
Plain ternaries remain fine when no narrowing is involved (`{open() ? <A /> : <B />}`).
|
|
379
|
+
|
|
380
|
+
Default `Show` keeps children mounted across truthy changes. Add `keyed` to remount when the
|
|
381
|
+
value's identity changes (child then receives the raw value, like React's `key` reset).
|
|
382
|
+
Prefer the default; use `keyed` only when internal state must reset. Multi-branch:
|
|
383
|
+
`<Switch>` / `<Match>`.
|
|
384
|
+
|
|
385
|
+
Components must **return once**: never early-return based on a reactive value — the branch
|
|
386
|
+
is picked at setup and frozen. Early returns on non-reactive values (build-time config, a
|
|
387
|
+
missing environment variable) are fine.
|
|
388
|
+
|
|
389
|
+
### Input filtering (inputs do not rewind)
|
|
390
|
+
|
|
391
|
+
React's controlled inputs force the DOM back to the state value on every re-render, so
|
|
392
|
+
"ignore invalid keystrokes" works by simply not updating state. Solid has no re-render:
|
|
393
|
+
`value={v()}` writes the DOM only when `v` changes, so rejected characters **stay visible**
|
|
394
|
+
unless you rewind the DOM yourself:
|
|
395
|
+
|
|
396
|
+
```tsx
|
|
397
|
+
<input
|
|
398
|
+
value={v()}
|
|
399
|
+
onInput={(event) => {
|
|
400
|
+
if (/^\d*$/.test(event.currentTarget.value)) setV(event.currentTarget.value);
|
|
401
|
+
else event.currentTarget.value = v(); // reject: explicit write-back
|
|
402
|
+
}}
|
|
403
|
+
/>
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### Context
|
|
407
|
+
|
|
408
|
+
```tsx
|
|
409
|
+
import { createContext, createSignal, useContext } from 'solid-js';
|
|
410
|
+
import type { Accessor, ParentProps, Setter } from 'solid-js';
|
|
411
|
+
|
|
412
|
+
type Theme = 'light' | 'dark';
|
|
413
|
+
|
|
414
|
+
const ThemeContext = createContext<{ theme: Accessor<Theme>; setTheme: Setter<Theme> }>();
|
|
415
|
+
|
|
416
|
+
function ThemeProvider(props: ParentProps) {
|
|
417
|
+
const [theme, setTheme] = createSignal<Theme>('light');
|
|
418
|
+
return <ThemeContext value={{ theme, setTheme }}>{props.children}</ThemeContext>;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function ThemeButton() {
|
|
422
|
+
const { theme, setTheme } = useContext(ThemeContext); // destructuring here is FINE
|
|
423
|
+
return <button onClick={() => setTheme('dark')}>{theme()}</button>;
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Pass accessors/setters/stores through context — never `value={theme()}` (a dead snapshot).
|
|
428
|
+
No `useMemo` for the value object, no context splitting: the object is created once and
|
|
429
|
+
fine-grained updates flow through the signals inside it.
|
|
430
|
+
|
|
431
|
+
### Refs
|
|
432
|
+
|
|
433
|
+
```tsx
|
|
434
|
+
import { onSettled, type Ref } from 'solid-js';
|
|
435
|
+
|
|
436
|
+
function listen(type: string, handler: EventListener, options?: AddEventListenerOptions) {
|
|
437
|
+
let element: HTMLElement | undefined;
|
|
438
|
+
onSettled(() => {
|
|
439
|
+
const target = element;
|
|
440
|
+
if (!target) return;
|
|
441
|
+
target.addEventListener(type, handler, options);
|
|
442
|
+
return () => target.removeEventListener(type, handler, options);
|
|
443
|
+
});
|
|
444
|
+
return (next: HTMLElement) => { element = next; };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function SearchField(props: { ref?: Ref<HTMLInputElement>; onInput: EventListener }) {
|
|
448
|
+
let input!: HTMLInputElement;
|
|
449
|
+
return (
|
|
450
|
+
<>
|
|
451
|
+
<input
|
|
452
|
+
ref={[props.ref, (el) => (input = el), listen('input', props.onInput, { passive: true })]}
|
|
453
|
+
type="search"
|
|
454
|
+
/>
|
|
455
|
+
<button type="button" onClick={() => input.select()}>Select</button>
|
|
456
|
+
</>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
Ref arrays flatten recursively; each callback runs in order. No `forwardRef` needed.
|
|
462
|
+
|
|
463
|
+
**Ref callbacks run untracked and without an owner.** Their return values are
|
|
464
|
+
ignored — do not create effects, memos, or `onCleanup` inside the callback, and
|
|
465
|
+
do not `return () => cleanup`. Put owned setup in a **directive factory** (like
|
|
466
|
+
`listen` above) and return only the element callback. Use this for
|
|
467
|
+
`ResizeObserver`, third-party widgets, and native listener options (`capture` /
|
|
468
|
+
`passive`) — Solid event props do not take those options.
|
|
469
|
+
|
|
470
|
+
## Scheduling and tests
|
|
471
|
+
|
|
472
|
+
Signal/store writes outside a synchronous flush scope are **staged**; the reactive queue
|
|
473
|
+
commits on the next microtask. Event handlers need nothing. Tests must flush:
|
|
474
|
+
|
|
475
|
+
```ts
|
|
476
|
+
setCount(2);
|
|
477
|
+
flush();
|
|
478
|
+
expect(count()).toBe(2);
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
Wait for an async expression to settle in tests: `await resolve(() => value())`.
|
|
482
|
+
Run tests in dev mode first — Solid 2 emits diagnostics for top-level reactive reads,
|
|
483
|
+
writes from owned scopes, and async reads outside `<Loading>`. Fix them; don't suppress.
|
|
484
|
+
|
|
485
|
+
Component tests use `@solidjs/testing-library`. Pass a **function** to `render` so
|
|
486
|
+
the tree has an owner, and `flush()` after interactions that stage writes:
|
|
487
|
+
|
|
488
|
+
```tsx
|
|
489
|
+
import { cleanup, fireEvent, render } from '@solidjs/testing-library';
|
|
490
|
+
import { flush } from 'solid-js';
|
|
491
|
+
import { afterEach, expect, test } from 'vitest';
|
|
492
|
+
|
|
493
|
+
afterEach(cleanup);
|
|
494
|
+
|
|
495
|
+
test('increments on click', () => {
|
|
496
|
+
const { getByRole } = render(() => <Counter />);
|
|
497
|
+
const button = getByRole('button');
|
|
498
|
+
fireEvent.click(button);
|
|
499
|
+
flush();
|
|
500
|
+
expect(button).toHaveTextContent('Clicks: 1');
|
|
501
|
+
});
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
`jsxImportSource` for tests and app code is `"@solidjs/web"`, not `"solid-js"`.
|
|
505
|
+
Vite plugin is `@solidjs/vite-plugin`, not `vite-plugin-solid`.
|
|
506
|
+
|
|
507
|
+
## App stack (only if the project has these packages)
|
|
508
|
+
|
|
509
|
+
This kit's always-applied rules cover `solid-js` + `@solidjs/web` TSX. Official
|
|
510
|
+
Solid 2 docs also describe optional app layers. **If those packages are in the
|
|
511
|
+
project, use them — do not invent Next.js, React Router, SolidStart 1.x, or
|
|
512
|
+
Solid Router 0.x/1.x stand-ins.** Patterns below; fetch the matching pages from
|
|
513
|
+
[references/official-docs.md](references/official-docs.md).
|
|
514
|
+
|
|
515
|
+
### Compiler / packages
|
|
516
|
+
|
|
517
|
+
```json
|
|
518
|
+
{ "compilerOptions": { "jsx": "preserve", "jsxImportSource": "@solidjs/web" } }
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Import DOM `JSX` types from `@solidjs/web`; renderer-neutral `Component` /
|
|
522
|
+
`ParentProps` from `solid-js`. Vite: `import solid from "@solidjs/vite-plugin"`
|
|
523
|
+
with `start: true` / `ssr: true` / `serverFunctions: true` only when the
|
|
524
|
+
project already uses start mode. Tiers are `bare` → `basic` (router) →
|
|
525
|
+
`fullstack` (SSR + server functions); do not jump a tier without cause.
|
|
526
|
+
|
|
527
|
+
### Solid Router 2 — `createRouter`, not JSX `<Route>`
|
|
528
|
+
|
|
529
|
+
```tsx
|
|
530
|
+
import { lazy } from 'solid-js';
|
|
531
|
+
import { createRouter } from '@solidjs/router';
|
|
532
|
+
|
|
533
|
+
export const Router = createRouter({
|
|
534
|
+
routes: [
|
|
535
|
+
{ path: '/', component: lazy(() => import('./pages/Home')) },
|
|
536
|
+
{ path: '/about', component: lazy(() => import('./pages/About')) },
|
|
537
|
+
{ path: '*404', component: lazy(() => import('./pages/NotFound')) },
|
|
538
|
+
],
|
|
539
|
+
});
|
|
540
|
+
export const { paths } = Router;
|
|
541
|
+
|
|
542
|
+
export default function App() {
|
|
543
|
+
return (
|
|
544
|
+
<Router>
|
|
545
|
+
{(props) => (
|
|
546
|
+
<>
|
|
547
|
+
<a href={Router.paths()}>Home</a>
|
|
548
|
+
<main>{props.children}</main>
|
|
549
|
+
</>
|
|
550
|
+
)}
|
|
551
|
+
</Router>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Create the instance at **module scope**. Nested layouts are `children` arrays
|
|
557
|
+
on the route objects, not nested `<Route>` / nested routers. Navigate with
|
|
558
|
+
plain `<a href={Router.paths.about}>` (or `useNavigate`); there is no `<A>` /
|
|
559
|
+
`<Navigate>` / `<HashRouter>`. Session location is `useLocation` / `useParams`,
|
|
560
|
+
not `Router.paths`. One router per app.
|
|
561
|
+
|
|
562
|
+
### Server functions — `"use server"`
|
|
563
|
+
|
|
564
|
+
```ts
|
|
565
|
+
export async function findUser(id: string) {
|
|
566
|
+
'use server';
|
|
567
|
+
// validate `id`; read identity from getRequestEvent(), never from arguments
|
|
568
|
+
return database.users.find(id);
|
|
569
|
+
}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
A function-level server function cannot close over component locals — pass
|
|
573
|
+
values as arguments. Treat every argument as untrusted. During SSR the same
|
|
574
|
+
reference runs in-process; in the browser it is HTTP. Mutations that should
|
|
575
|
+
revalidate: `redirect` / `reload` from `@solidjs/web` (see the server-function
|
|
576
|
+
guides). For 404 during SSR: `httpStatus(404)` from `@solidjs/web`.
|
|
577
|
+
`throw new Error("…")` is stripped to `"Internal Server Error"` in production —
|
|
578
|
+
use `markSafeError` or `respond(..., { status })` for intentional client-facing
|
|
579
|
+
failures. `GET()` is only for idempotent reads (URLs leak into logs/history).
|
|
580
|
+
`live()` yields **current state** (each yield replaces the last); do not treat
|
|
581
|
+
it as an append-only event log, and hoist the memo so consumers share one
|
|
582
|
+
connection.
|
|
583
|
+
|
|
584
|
+
### Document head — no MetaProvider
|
|
585
|
+
|
|
586
|
+
```tsx
|
|
587
|
+
import { Title, Link, Meta } from '@solidjs/meta';
|
|
588
|
+
|
|
589
|
+
<Title>Page title</Title>
|
|
590
|
+
<Meta name="description" content="..." />
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
Solid Meta 1.x has **no provider**. Render tags anywhere; later wins, unmount
|
|
594
|
+
restores. `useHead` from `@solidjs/web` is the lower-level registry. Do not
|
|
595
|
+
hardcode a second `<title>` in the document shell.
|
|
596
|
+
|
|
597
|
+
## These still run — write the other form
|
|
598
|
+
|
|
599
|
+
Official docs (and the compiler) allow several of these. They are the wrong
|
|
600
|
+
tool: they snapshot, drop subscriptions, or rebuild work Solid already knows
|
|
601
|
+
how to reuse. Prefer the form on the right.
|
|
602
|
+
|
|
603
|
+
| Compiles / renders | Write this |
|
|
604
|
+
|---|---|
|
|
605
|
+
| `{todos().map((t) => <Row todo={t} />)}` | `<For each={todos()} keyed={(t) => t.id}>` |
|
|
606
|
+
| `<For each={todos().map(t => t)}>` | derive first, then `<For each={visible()}>` |
|
|
607
|
+
| `class={`btn ${on() ? "on" : ""}`}` | `class={["btn", { on: on() }]}` |
|
|
608
|
+
| `style={{ width: 80 }}` (no unit) | `style={{ width: `${80}px` }}` |
|
|
609
|
+
| `const rest = { ...props }` | `const rest = omit(props, "label")` |
|
|
610
|
+
| `const user = createMemo(async () => { await fetch(...); return id(); })` | read `id()` **before** `await` |
|
|
611
|
+
| `action(async function* () { await save(); setX(v); })` | `yield save()` or `await save(); yield; setX(v)` |
|
|
612
|
+
| `todos()` on a store | `todos.length` / `todo.title` (property reads) |
|
|
613
|
+
| `setTodos((t) => t.filter((x) => x.ok))` | mutate the draft, or `reconcile` |
|
|
614
|
+
| `createEffect(() => user, (u) => log(u.name))` | `createEffect(() => user.name, (name) => log(name))` |
|
|
615
|
+
| `<Loading fallback={<PageSkeleton />}>{/* header + data */}</Loading>` | wrap only the data slot; chrome stays outside |
|
|
616
|
+
| `<Loading on={id} fallback={...}>` (the accessor) | `on={id()}` — a value, so identity changes can show fallback |
|
|
617
|
+
| `setCount(count() + 1)` when writes can batch | `setCount((c) => c + 1)` |
|
|
618
|
+
| `setHandler(fn)` to store a function | `setHandler(() => fn)` (otherwise `fn` is an updater) |
|
|
619
|
+
| `{user() ? <P user={user()!} /> : <SignIn />}` | `<Show when={user()}>{(u) => <P user={u()} />}</Show>` |
|
|
620
|
+
| `<Child user={user()} />` for an async memo | `<Child user={user} />` and read `props.user()` under the child's `<Loading>` |
|
|
621
|
+
| `lazy(() => import("./p").then((m) => ({ default: m.About })))` | `lazy(() => import("./p"), { export: "About" })` |
|
|
622
|
+
| `dangerouslySetInnerHTML={{ __html }}` | `innerHTML={html()}` (sanitized); never with JSX children |
|
|
623
|
+
| `onClick={setCount}` | `onClick={() => setCount((c) => c + 1)}` |
|
|
624
|
+
| `event.target.value` | `event.currentTarget.value` |
|
|
625
|
+
| `createContext(emptyStore)` for reactive state | `createContext<Todos>()` — dummy defaults silently no-op |
|
|
626
|
+
| `<For keyed={(t) => t.id}>{(todo) => todo.title}` | `todo().title` — key-function items are accessors |
|
|
627
|
+
| `{list().length ? <For each={list()}> : <Empty />}` | `<For each={list()} fallback={<Empty />}>` |
|
|
628
|
+
| `<For each={rows.slice(from, from + n)}>` | `<Repeat from={from()} count={n}>` |
|
|
629
|
+
| `{count() && <Badge />}` | `<Show when={count()}>` — `0` must not render as text |
|
|
630
|
+
| `throw new Error("expired")` from a server function (prod) | `throw markSafeError(...)` or `throw respond(body, { status })` |
|
|
631
|
+
| `GET(async (id) => { "use server"; await db.delete(id) })` | mutations stay on POST; `GET()` is for idempotent reads |
|
|
632
|
+
| `live()` yields as an event log to append | each yield **replaces** the current answer; yield current state first |
|
|
633
|
+
| `<article innerHTML={html()}>…children…</article>` | `innerHTML` **or** children, not both |
|
|
634
|
+
| `fallback={(error) => <p>{error.message}</p>}` | `error` is an accessor: `error().message` (or `String(error())`) |
|
|
635
|
+
|
|
636
|
+
```tsx
|
|
637
|
+
// WRONG — post-await read never subscribes; production can hang pending
|
|
638
|
+
const user = createMemo(async () => {
|
|
639
|
+
const response = await fetch('/api/me');
|
|
640
|
+
const extra = flag(); // too late
|
|
641
|
+
return { ...(await response.json()), extra };
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
// CORRECT
|
|
645
|
+
const user = createMemo(async () => {
|
|
646
|
+
const extra = flag();
|
|
647
|
+
const response = await fetch('/api/me');
|
|
648
|
+
return { ...(await response.json()), extra };
|
|
649
|
+
});
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
```tsx
|
|
653
|
+
// WRONG — rest is a plain object snapshot
|
|
654
|
+
function Field(props: { label: string; class?: string }) {
|
|
655
|
+
const rest = { ...props };
|
|
656
|
+
return <label>{props.label}<input {...rest} /></label>;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// CORRECT
|
|
660
|
+
function Field(props: { label: string; class?: string }) {
|
|
661
|
+
const rest = omit(props, 'label');
|
|
662
|
+
return <label>{props.label}<input {...rest} /></label>;
|
|
663
|
+
}
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
```tsx
|
|
667
|
+
// WRONG — user() throws not-ready at Page; child's <Loading> never sees it
|
|
668
|
+
function Page() {
|
|
669
|
+
const user = createMemo(async () => (await fetch('/api/me')).json());
|
|
670
|
+
return <Profile user={user()} />;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// CORRECT — pass the accessor; read it under the boundary that owns the fallback
|
|
674
|
+
function Profile(props: { user: Accessor<User> }) {
|
|
675
|
+
return (
|
|
676
|
+
<Loading fallback={<p>Loading…</p>}>
|
|
677
|
+
<h1>{props.user().name}</h1>
|
|
678
|
+
</Loading>
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
function Page() {
|
|
682
|
+
const user = createMemo(async () => (await fetch('/api/me')).json());
|
|
683
|
+
return <Profile user={user} />;
|
|
684
|
+
}
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
## Checking the official docs
|
|
688
|
+
|
|
689
|
+
Solid 2.0 docs: https://v2.solidjs.com/ — the site blocks non-browser fetchers, so from an
|
|
690
|
+
agent use the mirror, which serves every page as plain markdown:
|
|
691
|
+
|
|
692
|
+
- Index of all pages: `https://v2-rebuild--solid-docs-v2.netlify.app/llms.txt`
|
|
693
|
+
- Single-file corpus: `https://v2-rebuild--solid-docs-v2.netlify.app/llms-full.txt`
|
|
694
|
+
- Any page: append `.md`, e.g. `https://v2-rebuild--solid-docs-v2.netlify.app/reference/solid-js/reactivity/create-signal.md`
|
|
695
|
+
|
|
696
|
+
A curated URL list is in [references/official-docs.md](references/official-docs.md).
|
|
697
|
+
|
|
698
|
+
**Never** consult Solid 1.x sources (docs.solidjs.com, pre-2.0 tutorials, old Stack Overflow).
|
|
699
|
+
Solid 2.0 is a breaking rewrite; 1.x answers are wrong here. The banned-API table is in the
|
|
700
|
+
always-applied rules installed alongside this skill.
|
|
701
|
+
|
|
702
|
+
## Review checklist (before finishing any TSX change)
|
|
703
|
+
|
|
704
|
+
- [ ] No props destructuring; no `props.` at component-body top level.
|
|
705
|
+
- [ ] Every reactive read (`signal()`, `props.x`, `store.x`) sits in JSX, a memo, an effect
|
|
706
|
+
compute, or a boundary — not the component body.
|
|
707
|
+
- [ ] No signal-synced-by-effect; derived values are functions or memos.
|
|
708
|
+
- [ ] Event handlers wrap setters (`onClick={() => setX(...)}`), use `currentTarget`,
|
|
709
|
+
and `onInput` for keystrokes. `innerHTML` is sanitized and not mixed with children.
|
|
710
|
+
- [ ] Async memos are passed as accessors (`user={user}`), not `user={user()}`, so
|
|
711
|
+
`<Loading>` around the *read* can catch not-ready.
|
|
712
|
+
- [ ] Lists via `<For>` (server/refetched rows keyed by stable id), conditionals via
|
|
713
|
+
ternary/`<Show>`; no `{list().map(...)}` in JSX; no `key` props; no `value()!`.
|
|
714
|
+
- [ ] Effects are two-phase and only at imperative boundaries; apply does not read stores.
|
|
715
|
+
- [ ] Async reads sit under `<Loading>` (the data slot, not chrome); errors under `<Errored>`.
|
|
716
|
+
Reactive inputs of an async memo are read before the first `await`. No hand-rolled
|
|
717
|
+
`loading`/`error` signals or `=== undefined` readiness branches; refetch via
|
|
718
|
+
`refresh`, not counter signals.
|
|
719
|
+
- [ ] External collections reconcile into stores (function-form `createStore` /
|
|
720
|
+
`reconcile`), never wholesale draft assignment or `setTodos((t) => t.filter(...))`
|
|
721
|
+
as the identity-preserving update.
|
|
722
|
+
- [ ] Rest props via `omit`, not `{ ...props }`. Conditional `class` via objects, not string concat.
|
|
723
|
+
- [ ] Input filters write back to the DOM on reject (inputs do not rewind).
|
|
724
|
+
- [ ] No Solid 1.x imports or APIs (`solid-js/store`, `createResource`, `onMount`, `Suspense`, ...).
|
|
725
|
+
- [ ] Inspect children with `children()`; code-split with `lazy` + `<Loading>`; reactive
|
|
726
|
+
component choice with `dynamic()`. No `React.lazy`, no effects inside ref callbacks.
|
|
727
|
+
- [ ] Browser-only code uses `isServer` / `clientOnly`, not `typeof window`.
|
|
728
|
+
- [ ] If the project has a router: `createRouter({ routes })`, not JSX `<Route>` / `<A>`.
|
|
729
|
+
- [ ] `jsxImportSource` is `@solidjs/web`; Vite plugin is `@solidjs/vite-plugin`.
|
|
730
|
+
- [ ] Single return per component; no early returns on reactive conditions.
|
|
731
|
+
- [ ] `solid2-kit check` and the project's typecheck pass.
|