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.
@@ -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.