solid2-agent-kit 0.13.0 → 0.15.0

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/README.md CHANGED
@@ -20,7 +20,7 @@ Supported agents: **Cursor** and **Claude Code**.
20
20
  | Edit-time hook | `postToolUse` entry in `.cursor/hooks.json` | `PostToolUse` entry in `.claude/settings.json` (matcher includes `Bash`) | Runs the mechanical gate automatically on **every agent file edit — including edits made through shell commands** (sed, heredocs, codemods; source paths mentioned in the command are checked after it ran) — and feeds findings straight back into the conversation (Cursor: `additional_context`; Claude Code: stderr + exit 2), so enforcement does not depend on the agent remembering to run `check`. Wired only when the kit is a local devDependency; opt out with `--no-hooks` |
21
21
  | Turn-end gate | — | `Stop` entry in `.claude/settings.json` | Whole-project `check` + `doctor` when the agent tries to end its turn: with findings in place the stop is blocked (exit 2) and the findings are fed back, so a turn cannot finish with React/Solid 1.x patterns left behind. Loop-safe via `stop_hook_active` (one forced continuation, never an infinite loop) |
22
22
  | Pre-execution guard | `preToolUse` + `beforeShellExecution` entries in `.cursor/hooks.json` | `PreToolUse` entry in `.claude/settings.json` | Denies actions **before they run**: edits/deletes of kit-owned guardrail files (an agent blocked by a gate will try to remove the gate), edits that would strip the kit's hook entries or managed blocks, uninspectable shell rewrites of those files, and `npm/pnpm/yarn/bun install` of banned dependencies (`react`, `vite-plugin-solid`, `eslint-plugin-solid`, SolidStart, ...) — caught proactively instead of post-hoc by `doctor` |
23
- | Project-wiring gate | `solid2-kit doctor` | same | Config drift the source gate cannot see: React / `vite-plugin-solid` / `eslint-plugin-solid` / SolidStart deps in `package.json`, a 1.x `solid-js` range, `jsx` ≠ `preserve` or `jsxImportSource` ≠ `@solidjs/web` in tsconfig, Solid 1.x wiring in root config files, and stale installed guidance (kit updated but `sync` not re-run) |
23
+ | Project-wiring gate | `solid2-kit doctor` | same | Config drift the source gate cannot see: React / `vite-plugin-solid` / `eslint-plugin-solid` / SolidStart deps in `package.json`, a Solid 1.x line of `solid-js` / `@solidjs/router` / `@solidjs/meta` (declared, or installed from the `latest` tag — Solid 2 releases are on `next`), `jsx` ≠ `preserve` or `jsxImportSource` ≠ `@solidjs/web` in tsconfig, Solid 1.x wiring in root config files, and stale installed guidance (kit updated but `sync` not re-run) |
24
24
  | Review command | `.cursor/commands/solid2-review.md` | `.claude/commands/solid2-review.md` | `/solid2-review` — on-demand deep review: runs both gates, then walks the diff against the skill checklist targeting the semantic failure classes regexes cannot see (post-`await` reads, effect misuse, unkeyed server rows, boundary placement, context snapshots) |
25
25
 
26
26
  Managed blocks are delimited with `<!-- solid2-agent-kit:*:start/end -->` markers; everything
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // solid2-kit init [--cursor] [--claude] [--no-hooks] [--target <dir>]
6
6
  // solid2-kit sync [--cursor] [--claude] [--no-hooks] [--target <dir>] (alias of init)
7
- // solid2-kit check [--dir <srcdir>] [--target <dir>] [files...]
7
+ // solid2-kit check [--dir <srcdir>] [--target <dir>] [paths...]
8
8
  // solid2-kit doctor [--target <dir>]
9
9
  // solid2-kit hook (claude|cursor) (stdin: agent hook JSON payload)
10
10
  //
@@ -19,6 +19,7 @@ import {
19
19
  readFileSync,
20
20
  readdirSync,
21
21
  rmSync,
22
+ statSync,
22
23
  writeFileSync,
23
24
  } from 'node:fs';
24
25
  import { dirname, join, relative, resolve } from 'node:path';
@@ -404,6 +405,27 @@ function actionAsyncFindings(content) {
404
405
  }));
405
406
  }
406
407
 
408
+ // TanStack Router for Solid exports names the Next.js / Solid Router 1.x rules
409
+ // target (`useRouter()`, `notFound()`, `<Navigate>`). A match is exempt only
410
+ // when this file binds that exact local name from one of these modules, so a
411
+ // stray Next or Router 1.x call in the same file is still reported.
412
+ const TANSTACK_ROUTER_MODULE = /^@tanstack\/(?:solid-router|router-core)$/;
413
+
414
+ // Local names bound by `import [Default,] { a, b as c } from "<module>"`.
415
+ function importedNames(content, moduleName) {
416
+ const names = new Set();
417
+ for (const decl of content.matchAll(
418
+ /\bimport\s+(?:type\s+)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g,
419
+ )) {
420
+ if (!moduleName.test(decl[2])) continue;
421
+ for (const specifier of decl[1].split(',')) {
422
+ const local = specifier.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop();
423
+ if (local) names.add(local);
424
+ }
425
+ }
426
+ return names;
427
+ }
428
+
407
429
  const CHECKS = [
408
430
  {
409
431
  id: 'props-destructure-param',
@@ -560,6 +582,7 @@ const CHECKS = [
560
582
  {
561
583
  id: 'solid1-router',
562
584
  pattern: /<(?:HashRouter|MemoryRouter|Route|Navigate|A|FileRoutes|StartClient|StartServer)\b/g,
585
+ exemptImportsFrom: TANSTACK_ROUTER_MODULE,
563
586
  message:
564
587
  'Solid Router 0.x/1.x or SolidStart JSX. Define routes with createRouter({ routes }) / fileRoutes(pageRoutes) and plain <a href={Router.paths...}> (link state: automatic aria-current/data-active/data-pending + CSS, or useLinkState).',
565
588
  },
@@ -606,6 +629,7 @@ const CHECKS = [
606
629
  id: 'next-nav',
607
630
  pattern:
608
631
  /(?<![.\w])(?:useRouter|usePathname|revalidatePath|revalidateTag|notFound|hydrateRoot)\s*\(/g,
632
+ exemptImportsFrom: TANSTACK_ROUTER_MODULE,
609
633
  message:
610
634
  'Next.js / React DOM leftover. In-app navigation is useNavigate / <a href={Router.paths...}>; 404 is httpStatus(404); hydrate is hydrate(() => <App />, root).',
611
635
  },
@@ -716,11 +740,16 @@ function stripComments(content) {
716
740
  return out;
717
741
  }
718
742
 
743
+ // Dependencies and dot-directories (.git, build caches, installed agent
744
+ // guidance) are never project sources, so `check .` stays meaningful.
719
745
  function* walk(dir) {
720
746
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
721
747
  const path = join(dir, entry.name);
722
- if (entry.isDirectory()) yield* walk(path);
723
- else if (SOURCE_FILE.test(entry.name)) yield path;
748
+ if (entry.isDirectory()) {
749
+ if (entry.name !== 'node_modules' && !entry.name.startsWith('.')) yield* walk(path);
750
+ } else if (SOURCE_FILE.test(entry.name)) {
751
+ yield path;
752
+ }
724
753
  }
725
754
  }
726
755
 
@@ -733,7 +762,9 @@ function fileFindings(file, relativeTo) {
733
762
  const lines = raw.split('\n');
734
763
  const findings = [];
735
764
  for (const rule of CHECKS) {
765
+ const exempt = rule.exemptImportsFrom ? importedNames(content, rule.exemptImportsFrom) : null;
736
766
  for (const match of rule.find ? rule.find(content) : content.matchAll(rule.pattern)) {
767
+ if (exempt?.has(match[0].match(/[\w$]+/)[0])) continue;
737
768
  const lineNumber = content.slice(0, match.index).split('\n').length;
738
769
  findings.push(
739
770
  `${relative(relativeTo, file) || file}:${lineNumber} [${rule.id}] ${rule.message}\n > ${lines[lineNumber - 1].trim()}`,
@@ -745,12 +776,30 @@ function fileFindings(file, relativeTo) {
745
776
 
746
777
  function check() {
747
778
  const target = resolve(flagValue('--target', '.'));
748
- const fileArgs = positionalArgs();
779
+ const pathArgs = positionalArgs();
749
780
 
750
781
  let files;
751
- if (fileArgs.length > 0) {
752
- // Explicit file mode (used by agent hooks): check only the named sources.
753
- files = fileArgs.map((file) => resolve(target, file)).filter((file) => SOURCE_FILE.test(file));
782
+ let scanned;
783
+ let walked = true;
784
+ if (pathArgs.length > 0) {
785
+ // Explicit path mode: named directories are walked, named files are
786
+ // checked when they are .ts/.tsx/.jsx sources.
787
+ files = [];
788
+ scanned = pathArgs;
789
+ walked = false;
790
+ for (const arg of pathArgs) {
791
+ const path = resolve(target, arg);
792
+ if (!existsSync(path)) {
793
+ console.error(`solid2-kit check — path not found: ${path}`);
794
+ process.exit(2);
795
+ }
796
+ if (statSync(path).isDirectory()) {
797
+ walked = true;
798
+ files.push(...walk(path));
799
+ } else if (SOURCE_FILE.test(path)) {
800
+ files.push(path);
801
+ }
802
+ }
754
803
  } else {
755
804
  const srcDir = resolve(target, flagValue('--dir', 'src'));
756
805
  if (!existsSync(srcDir)) {
@@ -758,6 +807,22 @@ function check() {
758
807
  process.exit(2);
759
808
  }
760
809
  files = [...walk(srcDir)];
810
+ scanned = [relative(target, srcDir) || '.'];
811
+ }
812
+
813
+ // A walk that found no sources points at the wrong tree; passing it would
814
+ // read as a clean gate in CI and in agent loops. A list of named files with
815
+ // no sources among them (changed-file pipelines on a docs-only change) has
816
+ // nothing to gate and passes.
817
+ if (files.length === 0 && !walked) {
818
+ console.log('solid2-kit check — OK (no .ts/.tsx/.jsx sources among the named files; nothing to check).');
819
+ return;
820
+ }
821
+ if (files.length === 0) {
822
+ console.error(
823
+ `solid2-kit check — no .ts/.tsx/.jsx sources found in ${scanned.join(', ')}; nothing was checked. Point --dir or [paths...] at the source tree.`,
824
+ );
825
+ process.exit(2);
761
826
  }
762
827
 
763
828
  let findings = 0;
@@ -1063,6 +1128,33 @@ const BANNED_DEPS = {
1063
1128
  vinxi: 'SolidStart 1.x toolchain. Solid 2 uses @solidjs/vite-plugin directly.',
1064
1129
  };
1065
1130
 
1131
+ // Packages whose npm `latest` dist-tag is still the Solid 1.x line (solid-js
1132
+ // 1.9, @solidjs/router 1.0, @solidjs/meta 0.29): a bare `pnpm add <name>`
1133
+ // installs it. The Solid 2 releases are published under `next`.
1134
+ const SOLID2_LINE_PACKAGES = [
1135
+ { name: 'solid-js', id: 'solid-js-version', solid1: /^[\s^~=v]*[01](?:\.|$)/, fix: 'Install solid-js@next (^2).' },
1136
+ {
1137
+ name: '@solidjs/router',
1138
+ id: 'router-version',
1139
+ solid1: /^[\s^~=v]*[01](?:\.|$)/,
1140
+ fix: 'Router 1.x is JSX <Route> with a solid-js ^1 peer; install @solidjs/router@next (2.x, createRouter({ routes })).',
1141
+ },
1142
+ {
1143
+ name: '@solidjs/meta',
1144
+ id: 'meta-version',
1145
+ solid1: /^[\s^~=v]*0(?:\.|$)/,
1146
+ fix: 'Meta 0.x is built for Solid 1 (<MetaProvider> required); install @solidjs/meta@next (1.x).',
1147
+ },
1148
+ ];
1149
+
1150
+ function installedVersion(target, name) {
1151
+ try {
1152
+ return JSON.parse(readFileSync(join(target, 'node_modules', name, 'package.json'), 'utf8')).version;
1153
+ } catch {
1154
+ return undefined;
1155
+ }
1156
+ }
1157
+
1066
1158
  const CONFIG_SOURCE = /\.(?:m|c)?[jt]s$/;
1067
1159
 
1068
1160
  // Files installed by this kit that carry a version marker. When the kit
@@ -1079,9 +1171,21 @@ function doctorFindings(target) {
1079
1171
  for (const [name, message] of Object.entries(BANNED_DEPS)) {
1080
1172
  if (name in deps) report(`dep-${name.replace(/[@/]/g, '')}`, `package.json depends on "${name}". ${message}`);
1081
1173
  }
1082
- const solidRange = deps['solid-js'];
1083
- if (typeof solidRange === 'string' && /^[\s^~=v]*[01]\./.test(solidRange)) {
1084
- report('solid-js-version', `package.json pins solid-js "${solidRange}" — this kit teaches Solid 2.x; upgrade to ^2.`);
1174
+ for (const { name, id, solid1, fix } of SOLID2_LINE_PACKAGES) {
1175
+ const range = deps[name];
1176
+ if (typeof range === 'string' && solid1.test(range)) {
1177
+ report(id, `package.json pins ${name} "${range}" — the Solid 1.x line; this kit teaches Solid 2.x. ${fix}`);
1178
+ continue;
1179
+ }
1180
+ // A range such as "latest" or "*" only shows its line once resolved.
1181
+ if (range === undefined) continue;
1182
+ const installed = installedVersion(target, name);
1183
+ if (installed && solid1.test(installed)) {
1184
+ report(
1185
+ id,
1186
+ `node_modules has ${name} ${installed} (package.json: "${range}") — the Solid 1.x line; this kit teaches Solid 2.x. ${fix}`,
1187
+ );
1188
+ }
1085
1189
  }
1086
1190
 
1087
1191
  for (const entry of readdirSync(target, { withFileTypes: true })) {
@@ -1183,7 +1287,7 @@ switch (command) {
1183
1287
  'Usage:',
1184
1288
  ' solid2-kit init [--cursor] [--claude] [--no-hooks] [--target <dir>] install/update guidance + edit hooks (default: both tools)',
1185
1289
  ' solid2-kit sync [--cursor] [--claude] [--no-hooks] [--target <dir>] alias of init (idempotent)',
1186
- ' solid2-kit check [--dir <srcdir>] [--target <dir>] [files...] mechanical React/Solid 1.x pattern gate (default dir: src)',
1290
+ ' solid2-kit check [--dir <srcdir>] [--target <dir>] [paths...] mechanical React/Solid 1.x pattern gate (default dir: src)',
1187
1291
  ' solid2-kit doctor [--target <dir>] project-wiring gate: deps, tsconfig, root configs',
1188
1292
  ' solid2-kit hook (claude|cursor) edit-time gate for agent hooks (stdin: hook JSON payload)',
1189
1293
  ].join('\n'),
@@ -26,14 +26,35 @@ Steps:
26
26
  `Promise<User>` props;
27
27
  - `latest(selectedId)` as the default highlight (hold is the default);
28
28
  `isPending` treated as a global spinner instead of a per-expression question;
29
- - treating `<Errored>` as a terminal ErrorBoundary, or routing per-row mutation
30
- failures through it (those belong in the action / a projection-folded map);
29
+ - treating `<Errored>` as a terminal ErrorBoundary, or expecting it to catch
30
+ mutation failures (an error thrown out of an `action` reverts the overlay and
31
+ rejects the call — catch it in the action / a projection-folded map, or `.catch`
32
+ the call in the handler);
33
+ - effect `apply` functions with expression bodies (`(t) => (document.title = t)`,
34
+ `(v) => setX(v)`) — any return value but a cleanup halts reactivity; `onCleanup`
35
+ inside `apply` (never runs) or inside `onSettled`, and memos/effects/`flush()`
36
+ inside an `onSettled` callback (all throw);
37
+ - writes to a store outside its setter (`todos.push(x)`, `todo.done = ...` — silently
38
+ ignored) and `Map`/`Set`/`Date` fields mutated in a draft (not tracked);
39
+ - app-wide state as a module-level signal/store instead of a provider at the root
40
+ of `App` (shared across SSR requests);
41
+ - value-form `createOptimisticStore([])` / `createOptimistic(v)` holding durable
42
+ data (writes vanish on settle) instead of the function form refreshed after `yield`;
43
+ - route `preload` returning data read as `props.data` (captured once per match)
44
+ instead of `void getX(params.id)` + `createMemo(() => getX(props.params.id))`;
45
+ - `throw redirect()` / `return reload()` in server functions called without Solid
46
+ Router `action`/`query` (the caller gets a raw `Response` or `null`, never its
47
+ data), and POST forms built on a bare function's `.url` (TS2339) instead of a
48
+ router `action`;
31
49
  error reporting done as a side effect inside `fallback` instead of
32
50
  `configureClientErrors` / `configureServerErrors` / `render(..., { onError })`;
33
51
  - store setter callbacks that `await` (the draft closes when the callback returns —
34
52
  `[ASYNC_STORE_SETTER]`), store setters called at component-body top level, and
35
53
  `latest()` used as a null-safe read of an unsettled source;
36
54
  - nested fetches assumed to waterfall, or `<Loading>` lifted along with a lifted fetch;
55
+ - mutations on a value read through `live()` that settle on `yield save()` alone or
56
+ `refresh()` the live-derived store, instead of `yield until(predicate, { timeout })`
57
+ for the stream's echo (the old value flashes back when the overlay drops);
37
58
  - rewriting App with loading/error branches, or snapshot/restore, when wrapping
38
59
  a client store in server functions; disabling optimistic rows until ack;
39
60
  - tRPC / type-gen around `"use server"`, a client `fetch` after a mutation,
@@ -5,4 +5,4 @@
5
5
  - After editing TSX, run the `solid2-kit check` mechanical gate — it fails on React/Solid 1.x tokens and props destructuring. Use the project's `lint:solid` script if one exists; otherwise `npx solid2-agent-kit check` (with the kit as a devDependency) or `npx github:lightsound/solid2-agent-kit check`. An edit-time hook may also run this gate automatically after each file edit — treat its findings as errors to fix immediately, not warnings. Do not install `eslint-plugin-solid` (built for Solid 1.x; misreads Solid 2 idioms).
6
6
  - After editing `package.json`, `tsconfig*.json`, or root config files (Vite, ESLint), run `solid2-kit doctor` — it fails on React/Solid 1.x wiring: `react`/`vite-plugin-solid`/`eslint-plugin-solid`/SolidStart dependencies, 1.x `solid-js` ranges, and `jsx`/`jsxImportSource` values other than `preserve`/`@solidjs/web`.
7
7
  - The kit's guidance and gate files (`.cursor/rules/solid-2.mdc`, the `solid-2` skill directories, hook entries, managed blocks in this file and `CLAUDE.md`) are guardrails, not obstacles. Never edit, weaken, bypass, or delete them to make a failure go away — fix the flagged code. If a guardrail seems wrong, stop and tell the user.
8
- - Solid 2.0 shipped recently and breaks with 1.x — never trust pre-2.0 Solid docs, tutorials, or training knowledge. Verify APIs against https://v2-rebuild--solid-docs-v2.netlify.app/llms.txt (markdown mirror of https://v2.solidjs.com/, which blocks non-browser fetchers). If the project has `@solidjs/router`, server functions, or `@solidjs/meta`, use those official pages (see the skill's official-docs index) — not React Router, Next.js, SolidStart 1.x, or Solid Router 0.x/1.x JSX `<Route>`/`<A>`. Core `action` from `solid-js` is a generator transaction; router `action`/`query` from `@solidjs/router` are POST forms and a read cache. Core `refresh(source)`, router `revalidate(key)`, and server-function `return reload(...)` are three different APIs. Pass the accessor to `isPending(user)`, not `isPending(user())`. Pass async values as values (`user={user()}`) — JSX props are lazy; types stay `User`, not `Promise<User>`. `<Loading>` wraps the read, not the memo. Nested fetches run in parallel (components do not suspend). `isPending` is a per-expression question (`isPending(() => props.story)` / `isPending(selectedId)`), not a global spinner; `latest(selectedId)` is opt-in so highlight and content stay consistent by default. `<Errored>` heals when the source succeeds. Pass a function to `render`/`hydrate`: `render(() => <App />, root)`. Component teardown is `onSettled`, not `onCleanup`. One router instance; in-app navigation is `useNavigate` / `<a href>`, not `window.location`. Types: `solid-js` exports no `JSX` namespace — children/return types are `Element` from `solid-js`; DOM `JSX` types come from `@solidjs/web`. Reset-a-signal-on-change is a writable derivation (`createSignal(() => { source(); return initial; })`), never an effect calling the setter. Client mode mounts with `render()` into an empty body — no hydration mismatch exists, so never defer initial values to post-mount React-style (that can overwrite persisted state). Client → server is additive (same setters + `action` + server functions): do not rewrite App with loading/error branches, disable optimistic rows until ack, invent tRPC, or refetch in `hydrate`/`onSettled`. The graph continues across the wire (`live()` owns the client connection; `<Reveal>` not a delayed stream). A server bundle uses `handleRequest` / Fetchable `fetch`; client-only start is static `dist/client`. One browser-only value takes `ssrSource` on the memo/signal/derived store (`client`/`hybrid`), not a `clientOnly` component split. Router link state is automatic (`aria-current`/`data-active`/`data-pending` + CSS; `useLinkState`/`useIsRouting` in JSX); search params via `useSearchParams`; router forms validate on the server (`throw respond({issues},{400})`) with inline errors from `useSubmissions`. Fire-and-forget confirmations wait with `yield until(predicate, {timeout})`. Debounce at the handler; inputs feeding a request bind `latest(query)`. Dev diagnostics are errors: `[STRICT_READ_UNTRACKED]` means a read outside a tracking scope, `[ASYNC_STORE_SETTER]` means a store setter callback awaited (the draft is a synchronous transaction — await first, then write), and a held write needs `isPending`/`latest`/optimistic/`affects` feedback or dev + attribution reports `[SILENT_HOLD]`. Error monitoring is `configureClientErrors` / `configureServerErrors` (or `render(..., { onError })`), not a side effect inside `<Errored fallback>`; the server hook must return a reference or nothing, never the error itself. `<Dynamic>` is deprecated — use `dynamic()`. Observability is on by default in development: whenever a change touches stores, lists, async, actions, or effects, keep the dev console free of `[CODE]` findings and run an attribution session (`/__solid/diagnostics` `begin` → interact → `whyDidRun` / `costs` → `end` with `@solidjs/diagnostics` installed, otherwise `isDev`-guarded `attribution.enable({ log: false })` + `why()` / `costs()`) before finishing. `vite dev` is always the dev build; `solid({ observe: true })` is a production opt-in.
8
+ - Solid 2.0 shipped recently and breaks with 1.x — never trust pre-2.0 Solid docs, tutorials, or training knowledge. Verify APIs against https://v2-rebuild--solid-docs-v2.netlify.app/llms.txt (markdown mirror of https://v2.solidjs.com/, which blocks non-browser fetchers). If the project has `@solidjs/router`, server functions, or `@solidjs/meta`, use those official pages (see the skill's official-docs index) — not React Router, Next.js, SolidStart 1.x, or Solid Router 0.x/1.x JSX `<Route>`/`<A>`. Core `action` from `solid-js` is a generator transaction; router `action`/`query` from `@solidjs/router` are POST forms and a read cache. Core `refresh(source)`, router `revalidate(key)`, and server-function `return reload(...)` are three different APIs. Pass the accessor to `isPending(user)`, not `isPending(user())`. Pass async values as values (`user={user()}`) — JSX props are lazy; types stay `User`, not `Promise<User>`. `<Loading>` wraps the read, not the memo. Nested fetches run in parallel (components do not suspend). `isPending` is a per-expression question (`isPending(() => props.story)` / `isPending(selectedId)`), not a global spinner; `latest(selectedId)` is opt-in so highlight and content stay consistent by default. `<Errored>` heals when the source succeeds. Pass a function to `render`/`hydrate`: `render(() => <App />, root)`. Component teardown is `onSettled`, not `onCleanup` — return the cleanup; `onCleanup`, memo/effect creation, and `flush()` throw inside the `onSettled` callback in dev. Effect `apply` functions use block bodies (`(t) => { document.title = t; }`): any return value other than a cleanup function halts reactivity. Stores are written only through the setter's draft — writes to the store itself are silently ignored, and `Map`/`Set`/`Date` inside a store are not tracked. Shared state, app-wide included, is created in a provider at the root of `App` (`createContext<T>()` + `useX()`), never a module-level signal/store (one instance serves every SSR request). Errors thrown out of an `action` never reach `<Errored>` — catch them in the action or `.catch` the call. One router instance; in-app navigation is `useNavigate` / `<a href>`, not `window.location`. Types: `solid-js` exports no `JSX` namespace — children/return types are `Element` from `solid-js`; DOM `JSX` types come from `@solidjs/web`. Reset-a-signal-on-change is a writable derivation (`createSignal(() => { source(); return initial; })`), never an effect calling the setter. Client mode mounts with `render()` into an empty body — no hydration mismatch exists, so never defer initial values to post-mount React-style (that can overwrite persisted state). Client → server is additive (same setters + `action` + server functions): do not rewrite App with loading/error branches, disable optimistic rows until ack, invent tRPC, or refetch in `hydrate`/`onSettled`. The graph continues across the wire (`live()` owns the client connection; `<Reveal>` not a delayed stream). A server bundle uses `handleRequest` / Fetchable `fetch`; client-only start is static `dist/client`. One browser-only value takes `ssrSource` on the memo/signal/derived store (`client`/`hybrid`), not a `clientOnly` component split. Router link state is automatic (`aria-current`/`data-active`/`data-pending` + CSS; `useLinkState`/`useIsRouting` in JSX); search params via `useSearchParams`; router forms validate on the server (`throw respond({issues},{400})`) with inline errors from `useSubmissions`. Confirmations on another channel — fire-and-forget sends, and mutations whose result is read through `live()` — wait with `yield until(predicate, {timeout})`, not `refresh()`. Declare `live(GET(fn))` (`live()` outermost). Debounce at the handler; inputs feeding a request bind `latest(query)`. Dev diagnostics are errors: `[STRICT_READ_UNTRACKED]` means a read outside a tracking scope, `[ASYNC_STORE_SETTER]` means a store setter callback awaited (the draft is a synchronous transaction — await first, then write), and a held write needs `isPending`/`latest`/optimistic/`affects` feedback or dev + attribution reports `[SILENT_HOLD]`. Error monitoring is `configureClientErrors` / `configureServerErrors` (or `render(..., { onError })`), not a side effect inside `<Errored fallback>`; the server hook must return a reference or nothing, never the error itself. `<Dynamic>` is deprecated — use `dynamic()`. Observability is on by default in development: whenever a change touches stores, lists, async, actions, or effects, keep the dev console free of `[CODE]` findings and run an attribution session (`/__solid/diagnostics` `begin` → interact → `whyDidRun` / `costs` → `end` with `@solidjs/diagnostics` installed, otherwise `isDev`-guarded `attribution.enable({ log: false })` + `why()` / `costs()`) before finishing. `vite dev` is always the dev build; `solid({ observe: true })` is a production opt-in.
@@ -75,7 +75,14 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
75
75
  fed to `<For>` (that rebuilds the window array every time).
76
76
  8. **Effects have two phases**: `createEffect(compute, apply)`. All reactive reads go in
77
77
  `compute`; its return value feeds `apply`, which does imperative work and may return a
78
- cleanup. Single-argument `createEffect(fn)` is an error in Solid 2. Do not substitute
78
+ cleanup. `apply` returns a cleanup function or nothing — **write it with a block body**:
79
+ `(t) => { document.title = t; }`, never `(t) => (document.title = t)` or
80
+ `(v) => setX(v)` (an assignment or setter returns its value; dev throws
81
+ `invalid cleanup value`, production fails on the next run, and either halts the whole
82
+ reactive system). An `apply` that only calls a local setter is still rule 4 with a
83
+ block body — make it a derivation. Register an effect's cleanup by returning it
84
+ from `apply` — `onCleanup` there never runs (`[NO_OWNER_CLEANUP]`).
85
+ Single-argument `createEffect(fn)` is an error in Solid 2. Do not substitute
79
86
  `createTrackedEffect` for that — it is an advanced one-callback form that cannot nest
80
87
  primitives. Skip the initial run with `{ defer: true }`. Most React `useEffect` code should not become an effect at all — see the skill.
81
88
  9. **Stores update by mutating a draft**: `setStore(draft => { draft.user.name = "Ada" })`.
@@ -86,6 +93,12 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
86
93
  `reconcile` or the derived form (dev + attribution flags the store-level waste as
87
94
  `[IMMUTABLE_UPDATE_IN_STORE]`). Stores are
88
95
  **property reads** (`todos.length`, `todo.title`), not accessors — never `todos()`.
96
+ The store itself is read-only: `todos.push(x)`, `state.count++`, or
97
+ `todo.done = !todo.done` outside a setter is **silently ignored** (no throw, no
98
+ warning, and TypeScript accepts it) — every write goes through the setter's draft.
99
+ `Map`, `Set`, and `Date` are stored as-is, not proxied: `draft.selected.add(id)`
100
+ changes the object but notifies nobody. Model sets as `Record<id, true>` (or an
101
+ array), or assign a new instance in the setter (`draft.tags = new Set([...draft.tags, t])`).
89
102
  The setter callback is a **synchronous** transaction: `setStore(async (draft) => …)`
90
103
  throws `[ASYNC_STORE_SETTER]` in dev (writes after the first `await` were silently
91
104
  lost before). `await` first, then call the setter (inside an `action`, `yield` first).
@@ -143,9 +156,16 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
143
156
  `createContext<T>()` without a default throws when read outside a provider (good) — use
144
157
  that for any reactive payload. `createContext("light")` is only for a primitive static
145
158
  fallback; a dummy default on a store/signal context *runs* and then silently no-ops
146
- without a provider. Truly app-wide singletons (theme, session, locale) are a
147
- **module-level** signal/store — Context is subtree scoping, not a React-style
148
- app store. Module state is shared across SSR requests.
159
+ without a provider. **Shared state lives in a provider, including app-wide state**
160
+ (theme, session, cart, locale): a factory such as `createCart()` called by a provider
161
+ component placed at the root of `App`, read through `createContext<T>()` (no default)
162
+ and a `useCart()` hook. Module scope is for constants and the `createContext` call itself —
163
+ never `export const [session, setSession] = createStore(...)`. Module state has no
164
+ owner, and under `ssr: true` one module instance serves every request, so one
165
+ visitor's state renders into another's HTML (`[SERVER_WRITE]`); a module-level
166
+ store derived from a server function throws `Cannot call server function outside
167
+ of a request` at import. A client-only build happens to work, until the day SSR is
168
+ turned on.
149
169
  12. Refs: `let el!: HTMLDivElement` + `ref={el}` or `ref={(node) => (el = node)}`, or forward
150
170
  `props.ref`. Compose with arrays: `ref={[props.ref, (node) => (el = node)]}`. No
151
171
  `useRef`/`.current`. Ref callbacks run untracked and **without an owner**; their
@@ -155,12 +175,18 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
155
175
  `onSettled(() => { ...; return cleanup })` — `onCleanup` is for custom
156
176
  primitives and captured owners, not component bodies. Each `onSettled`
157
177
  registers a **single** fire (reads inside are untracked); ongoing
158
- imperative work is `createEffect`.
178
+ imperative work is `createEffect`, created in the component body — not inside
179
+ the callback. The `onSettled` callback is a restricted scope: `onCleanup`
180
+ (`[CLEANUP_IN_FORBIDDEN_SCOPE]`), creating a memo or effect
181
+ (`[PRIMITIVE_IN_FORBIDDEN_SCOPE]`), and `flush()` all throw there in dev and halt
182
+ the reactive system. A 1.x `onMount(() => { …; onCleanup(…) })` becomes
183
+ `onSettled(() => { const id = setInterval(tick, 1000); return () => clearInterval(id); })`.
159
184
  13. **Writes are staged** and commit on the next microtask. Event handlers need nothing
160
185
  special; tests and imperative integration code must call `flush()` before observing
161
186
  updated *synchronous* state or DOM. Waiting on an async memo is
162
187
  `await resolve(() => value())` (or Testing Library async queries), not `flush()`.
163
- Never call `flush()` inside an `action`.
188
+ Never call `flush()` inside an `action`, an `onSettled` callback (throws in dev), or an
189
+ effect callback (a no-op, `[FLUSH_IN_EFFECT_CALLBACK]`).
164
190
  14. Do not port these React tools — they have no Solid equivalent because the problems they
165
191
  solve don't exist: `useCallback`, `React.memo`, `forwardRef`, `useSyncExternalStore`,
166
192
  dependency arrays, `startTransition`/`useTransition` (updates are held and coordinated
@@ -190,8 +216,12 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
190
216
  by `<Errored>`; speculative value = `latest()`. `<Errored>` is graph status, not a
191
217
  terminal React ErrorBoundary: it heals when the source succeeds again (refresh,
192
218
  live reconnect, input change). `reset` retries the collected *sources*, not a UI
193
- remount. Per-row mutation failures belong in an errors map the projection folds
194
- in (survives the optimistic overlay), not in `<Errored>` around the whole list.
219
+ remount. `<Errored>` catches failing *reads* and renders, never a mutation: an
220
+ error that escapes an `action` discards its optimistic writes and rejects the
221
+ promise the call returned — no boundary sees it, and uncaught it is an unhandled
222
+ rejection with the UI silently reverted. Catch expected failures inside the action
223
+ (per-row: an errors map the projection folds in, which survives the optimistic
224
+ overlay; otherwise a toast), and `.catch` the call in the handler for the rest.
195
225
  Error monitoring is a hook, not a side effect in the fallback: the boundary-caught
196
226
  failures no global handler sees reach `configureClientErrors({ onError })` from
197
227
  `solid-js` (or `render(..., { onError })` per root) and `configureServerErrors({ onError })`
@@ -200,7 +230,8 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
200
230
  nothing or a reference id, never the error itself (message/stack/secrets would
201
231
  reach the browser).
202
232
  Making a client store "real" is additive: same setters, wrap mutations in
203
- `action`, swap `createOptimisticStore` and a file of server functions — do
233
+ `action`, swap in the **function form** `createOptimisticStore(() => api.list(), [])`
234
+ (with `refresh` after the `yield`) and a file of server functions — do
204
235
  not rewrite `App.tsx` with loading/error branches.
205
236
  No request counters or `AbortController`s for dedup/cancellation — superseded
206
237
  answers drop automatically. Throw unusable responses; never `{ success: false }`
@@ -233,7 +264,12 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
233
264
  `postMessage` / logs via `snapshot(store)` (proxies fail or leak reactivity); render
234
265
  modals/tooltips/overlays through `<Portal>` from `@solidjs/web`; mutations whose writes
235
266
  cross an async gap default to `action` + `createOptimistic`/`createOptimisticStore` —
236
- the sync mutation *is* the prediction (an overlay, discarded on settle); write the
267
+ the sync mutation *is* the prediction (an overlay, discarded on settle). Durable data
268
+ therefore lives in the function form (`createOptimisticStore(() => api.list(), [])`,
269
+ or one that reads a separate `createStore`); the value form
270
+ `createOptimisticStore([])` / `createOptimistic(false)` is a pure overlay with no
271
+ durable layer — action writes vanish on settle (a saved row disappears) and writes
272
+ outside an action do not stick — so use it only for in-flight flags. Write the
237
273
  body as a generator (`function*` / `async function*`) suspending on `yield`, never a
238
274
  plain `async` function (`solid2-kit check` flags that). After a bare `await`, put a
239
275
  bare `yield` before the next write **and** before anything that creates a reader
@@ -242,9 +278,16 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
242
278
  `pending` on the record rather than a second copy of state. Do not snapshot
243
279
  and restore, disable optimistic rows until ack, mutex rapid clicks, or freeze
244
280
  unrelated writes because one action is in flight; failed-action replay is
245
- optional. Fire-and-forget confirmations wait with `yield until(predicate,
246
- { timeout })` — the predicate reads authoritative state. Except reactive clients (e.g. Convex) whose subscriptions already push authoritative
247
- state after mutations. Compiler: `"jsxImportSource": "@solidjs/web"` (not `"solid-js"`);
281
+ optional. Confirmations that arrive on another channel wait with `yield until(predicate,
282
+ { timeout })` — the predicate reads authoritative state, so the overlay cannot satisfy it.
283
+ That covers fire-and-forget sends **and** request/response mutations whose result is
284
+ read through a `live()` source: `live()` takes no part in revalidation or single-flight,
285
+ so after `yield save()` alone the overlay drops before the stream echoes the write and
286
+ the old value flashes back. Follow it with `yield until(...)`, not `refresh(liveStore)`
287
+ (that re-runs the derivation — the stream closes and the source reconnects — so each
288
+ mutation costs a reconnect, and the flash remains whenever the new connection's first
289
+ value predates the write). Skip `until` only for reactive clients (e.g. Convex)
290
+ whose subscriptions already carry the write when the mutation resolves. Compiler: `"jsxImportSource": "@solidjs/web"` (not `"solid-js"`);
248
291
  Vite plugin is `@solidjs/vite-plugin` (not `vite-plugin-solid`) — run `solid2-kit doctor`
249
292
  after touching `package.json` / tsconfig / root configs; it fails on React and Solid 1.x
250
293
  wiring. Server vs browser:
@@ -302,15 +345,25 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
302
345
  `revalidate(getUser.key)`. Do not wrap mutations in `query` (an undeclared server
303
346
  function becomes GET). `Router.paths` is not the current location (`useLocation` /
304
347
  `useParams`); search params via `useSearchParams` (merging setter; typed with a route
305
- `search` schema + path node). A route `preload` result is `props.data`. One router instance —
348
+ `search` schema + path node). A route `preload` *starts* reads and returns nothing —
349
+ `preload: ({ params }) => void getProduct(params.id)` (wrap the route in `defineRoute`
350
+ to type `params`) — and the page reads the same query in a memo:
351
+ `createMemo(() => getProduct(props.params.id))`. `props.data` is whatever `preload`
352
+ returned when the route first matched: it does not follow param changes (and an async
353
+ `preload` makes it a Promise), so return a value only for data that stays fixed while
354
+ the route is matched. One router instance —
306
355
  Solid Router does not support nested `<Router>` / nested `createRouter`.
307
356
  In-app navigation is `useNavigate` or `<a href={Router.paths...}>`, not
308
357
  `window.location` / `history.pushState`. Link state is automatic
309
358
  (`aria-current` / `data-active` / `data-pending` + CSS; `useLinkState` /
310
359
  `useIsRouting` in JSX) — never hand-rolled `location.pathname` comparisons. Trusted identity is
311
- `getRequestEvent()`, never a caller-supplied user id. Unscripted forms use
312
- the function `.url` (or a router `action`), not a hand-built `/_server/`
313
- path. Client history adapters do not select the SSR URL — pass
360
+ `getRequestEvent()`, never a caller-supplied user id. Unscripted (no-JS) POST
361
+ forms use a router `action` over a `(form: FormData)` server function (the post
362
+ arrives as one `FormData`; other signatures make `action={save}` TS2322) —
363
+ `<form action={save} method="post">` — not the
364
+ function's `.url` and not a hand-built `/_server/` path: a bare `"use server"`
365
+ function has no typed `.url` (TS2339). `.url` is typed only on `GET()` / `live()`
366
+ references, for `method="get"` search forms. Client history adapters do not select the SSR URL — pass
314
367
  `<Router url={request.url}>` (or rely on the request event). Secrets belong in
315
368
  `virtual:env/server`, never the `client` env map / `import.meta.env`. Document head is
316
369
  `<Title>` / `<Meta>` from `@solidjs/meta` with **no** `MetaProvider`. A static
@@ -323,8 +376,15 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
323
376
  fallback printing `err().message` shows `"Internal Server Error"` in a production
324
377
  server render) — so intentional client-facing failures are `markSafeError(...)` or
325
378
  `throw respond(body, { status })`. Reads are function calls too (`await getUser(id)`
326
- on a `GET()`-declared read; the method mirrors the operation). Return or throw a
327
- redirect (`if (!session?.userId) throw redirect("/sign-in")`); `redirect()` /
379
+ on a `GET()`-declared read; the method mirrors the operation). `redirect()` /
380
+ `reload()` are signals for an integration: Solid Router's `action` / `query` and the
381
+ no-JS form post apply them. A direct caller — a core `action`, a handler, a plain
382
+ memo — never gets your data: it receives the raw `Response` as the resolved value
383
+ (typed as your data, no navigation); only when a mounted router's single-flight
384
+ hook intercepts a direct POST call does it navigate, and the call then resolves to
385
+ `null`. So guards `throw redirect("/sign-in")` only when router `action` /
386
+ `query` call the function; without the router, `throw respond(null, { status: 401 })`
387
+ (the call rejects) or return a value and navigate in the caller. `redirect()` /
328
388
  `respond()` take `revalidate` like `reload()`. Cache GET reads with
329
389
  `Cache-Control` on that response (the default is `no-store`), not a hand-rolled
330
390
  Solid cache. HTTP does not enforce TypeScript —
@@ -346,7 +406,10 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
346
406
  components from `"use server"` unless the project already enabled the
347
407
  experimental `serverFunctions.components` flag. JSON-encodable server-function arguments
348
408
  only, unless `enableRichArguments()` was called once in the client entry
349
- (`Date` / `Map` / `Set` throw without it). `live()` connection state is
409
+ (`Date` / `Map` / `Set` throw without it). Declare live reads as `live(GET(fn))` —
410
+ `live()` outermost. A plain `async function*` server function is an event stream on
411
+ one connection (no reconnect when it drops); `live()` is one value that changes over
412
+ time and reconnects. `live()` connection state is
350
413
  `source.onstatus` (`"connected"` / `"reconnecting"` / `"closed"`), never a
351
414
  field in the yielded value. Sibling `<Loading>` reveal order is `<Reveal>`
352
415
  (`collapsed` suppresses tail skeletons under sequential order).
@@ -385,7 +448,7 @@ directory (default `src/`).
385
448
  | `import type { JSX } from "solid-js"` / `JSX.Element` as the children type | `solid-js` exports no `JSX` namespace: children/returns are `Element` from `"solid-js"`; DOM-specific `JSX` types (`JSX.IntrinsicElements`, `JSX.CSSProperties`) from `"@solidjs/web"` |
386
449
  | `createResource` | async `createMemo` + `<Loading>`/`<Errored>`; `refresh()`, `latest()`, `isPending()` |
387
450
  | `createEffect(fn)` (one arg), `on(...)` | `createEffect(compute, apply)`; deps belong in `compute` |
388
- | `onMount` | `onSettled` (return cleanup from its callback) |
451
+ | `onMount` | `onSettled(() => { ...; return cleanup })` — return the cleanup; `onCleanup`, memo/effect creation, and `flush()` throw inside the callback |
389
452
  | `batch(...)` | delete it — writes auto-batch; `flush()` only to observe synchronously |
390
453
  | `<Suspense>`, `<ErrorBoundary>`, `<SuspenseList>` | `<Loading>`, `<Errored>` (fallback gets an error *accessor*), `<Reveal>` |
391
454
  | `<Index>` | `<For keyed={false}>` |
@@ -393,7 +456,7 @@ directory (default `src/`).
393
456
  | `setState("a", "b", value)` path setters, `produce(...)` | draft setter: `setState(draft => { ... })` |
394
457
  | `mergeProps` / `splitProps` / `unwrap` | `merge` / `omit` / `snapshot` |
395
458
  | `classList={...}` | `class` object/array form |
396
- | `createMutable`, `modifyMutable` | `createStore` + draft setters |
459
+ | `createMutable`, `modifyMutable` | `createStore` + draft setters (direct writes to the store are silently ignored) |
397
460
  | `createComputed`, `createSelector`, `createDeferred` | `createMemo`, `createProjection`, external scheduling |
398
461
  | `startTransition`, `useTransition` | automatic held updates + `isPending` |
399
462
  | `onError` / `catchError` | `<Errored>` or effect bundle `{ effect, error }` |
@@ -41,7 +41,8 @@ Practical consequences:
41
41
  | Expensive derivation, multiple consumers, or equality boundary needed | `createMemo(() => ...)` |
42
42
  | Follows a prop but user can locally override; prop change resets | writable derivation: `createSignal(() => props.value)` / `createStore(() => props.value, fallback)` |
43
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) |
44
+ | App-wide state (theme, session, cart, locale) | the same provider, placed at the root of `App` — not a module-level signal/store (under SSR one module instance serves every request) |
45
+ | Constants (a category list, a formatter, the `createContext` call) | module scope |
45
46
  | Tentative value during a mutation | `createOptimistic` / `createOptimisticStore` + `action` |
46
47
  | 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
 
@@ -158,6 +159,32 @@ at component-body top level is a write in an owned scope — dev throws
158
159
  `[REACTIVE_WRITE_IN_OWNED_SCOPE]` (rc.9 closed the exemption store writes had) — so
159
160
  seed the shape through the `createStore` argument or a derivation.
160
161
 
162
+ **The store itself is read-only; only the draft writes.** An assignment or mutating
163
+ call on the store proxy outside a setter is *ignored* — no throw, no dev warning, the
164
+ value does not change, and TypeScript accepts it (`Store<T>` is `T`). This is the
165
+ MobX / Vue / Solid 1 `createMutable` reflex:
166
+
167
+ ```tsx
168
+ // WRONG — all silently ignored
169
+ todos.push(todo);
170
+ state.count++;
171
+ todo.done = !todo.done;
172
+
173
+ // CORRECT
174
+ setTodos((draft) => { draft.push(todo); });
175
+ setState((draft) => { draft.count++; });
176
+ setTodos((draft) => {
177
+ const row = draft.find((t) => t.id === id);
178
+ if (row) row.done = !row.done;
179
+ });
180
+ ```
181
+
182
+ Plain objects and arrays are proxied; `Map`, `Set`, and `Date` are stored as they are.
183
+ `draft.selected.add(id)` inside a setter mutates the `Set` but notifies nobody. Keep a
184
+ set of ids as `Record<string, true>` (`draft.selected[id] = true` /
185
+ `delete draft.selected[id]`, both tracked) or an array, or assign a new instance
186
+ (`draft.tags = new Set([...draft.tags, tag])`) so the property itself changes.
187
+
161
188
  **External collections enter stores through reconciliation, never wholesale assignment.**
162
189
  `draft.todos = serverTodos` renders correctly but replaces every object identity — all
163
190
  subscribers under the path re-run and row identity is lost. In order of preference:
@@ -248,6 +275,25 @@ createEffect(
248
275
  );
249
276
  ```
250
277
 
278
+ `apply` returns a cleanup function or nothing — write it with a **block body**. An
279
+ expression-bodied arrow returns whatever the expression evaluates to: an assignment
280
+ (`(t) => (document.title = t)`) returns the string, and a setter (`(v) => setDraft(v)`)
281
+ returns the value it set. Dev throws `effect callback returned an invalid cleanup value`,
282
+ production throws a `TypeError` on the next run, and both halt the whole
283
+ reactive system (`[REACTIVITY_HALTED]`). TypeScript reports it as TS2345; a JS project
284
+ gets no warning. The cleanup of an effect is the `apply` return value — `onCleanup`
285
+ inside `apply` has no owner and never runs (`[NO_OWNER_CLEANUP]`).
286
+
287
+ ```tsx
288
+ // WRONG — returns the assigned string; halts reactivity (dev: first run, prod: next run)
289
+ createEffect(() => title(), (t) => (document.title = t));
290
+
291
+ // CORRECT
292
+ createEffect(() => title(), (t) => {
293
+ document.title = t;
294
+ });
295
+ ```
296
+
251
297
  Reads inside `apply` do not track. Extract every needed reactive value in `compute`
252
298
  (e.g. `() => ({ name: user.name, role: user.role })`). Passing a store proxy into `apply`
253
299
  and reading `user.name` there *runs once* and never retriggers. Compute-phase errors can be
@@ -572,12 +618,28 @@ function createTodos() { // call from a component — not module scope (SSR shar
572
618
  Three layers, in this order: durable source → ephemeral UI that must survive
573
619
  overlay discard (the `errors` map, folded in the projection) → optimistic overlay
574
620
  (`pending`, the predicted `completed`). Consumers read one store. Catch
575
- *expected* mutation failures in the action so a toggle error does not blank the
576
- list through `<Errored>`; unhandled projection/render errors still reach the
577
- boundary.
621
+ *expected* mutation failures in the action, because nothing else will show them:
622
+ an error that escapes an action discards its optimistic writes and rejects the
623
+ promise the call returned — it never reaches `<Errored>`, which catches failing
624
+ reads and renders only (projection and render errors still reach the boundary).
625
+ Uncaught, the UI silently reverts and the rejection is unhandled. Put expected
626
+ failures in the errors map or a toast inside the action, and `.catch` the call in
627
+ the handler for the rest:
628
+
629
+ ```tsx
630
+ <button
631
+ type="button"
632
+ onClick={() => {
633
+ removeTodo(todo.id).catch(() => showToast('Could not delete'));
634
+ }}
635
+ >
636
+ Delete
637
+ </button>
638
+ ```
578
639
 
579
640
  Going from a client store to the server is **additive**: same `setTodos`
580
- calls, wrap each mutation in `action`, swap in `createOptimisticStore` and a
641
+ calls, wrap each mutation in `action`, swap in the **function form**
642
+ `createOptimisticStore(() => api.list(), [])` (with `refresh` after the `yield`) and a
581
643
  file of server functions. Do not rewrite `App.tsx` with loading/error branches,
582
644
  and do not snapshot-the-cache / write-a-prediction / restore-on-error. A cache
583
645
  library is not the productionizing step — the sync mutation already was the
@@ -594,6 +656,16 @@ Ordinary signal/store writes inside an action are held until it settles. Never c
594
656
  Do not set a `submitted` signal and watch it from an effect — that work belongs in the
595
657
  handler or the `action`.
596
658
 
659
+ **The value form has no durable layer.** `createOptimisticStore([])` (or
660
+ `createOptimistic(value)`) is a pure overlay: writes inside an action vanish when it
661
+ settles, and writes outside an action do not stick. Copying the reference example
662
+ `createOptimisticStore<Todo[]>([])` and writing the saved row after the `yield`
663
+ leaves an empty list once the action settles, with no warning. Durable data comes
664
+ from the function form — `createOptimisticStore(() => api.list(), [])` refreshed
665
+ after the `yield`, or `createOptimisticStore(() => base.todos, [])` over a separate
666
+ `createStore` that the action writes the confirmed value into. Keep the value form for
667
+ in-flight flags.
668
+
597
669
  `createOptimistic` / `createOptimisticStore` are for a tentative value during an
598
670
  active mutation, not a local editing session (use a writable derivation or a plain
599
671
  store for that). A whole-form `createOptimistic(false)` saving flag is fine; prefer
@@ -632,6 +704,38 @@ only the echo can. Timeout rejects with `TimeoutError` (strongly recommended on
632
704
  droppable transports; abort via `signal`). Outside actions, `await until(() => user())`
633
705
  waits for a value before continuing.
634
706
 
707
+ The same hold applies when the mutation *does* answer but the value is read through a
708
+ `live()` source. A live source updates only through its open stream — it takes no part
709
+ in revalidation or single-flight mutation data — so the mutation's response and the
710
+ stream's echo race. Settle on `yield saveBid(...)` alone and the overlay is discarded
711
+ first: the old bid flashes back until the stream catches up.
712
+
713
+ ```tsx
714
+ import { action, createOptimisticStore, until } from 'solid-js';
715
+ import { liveAuction, saveBid } from '../data/auction';
716
+
717
+ function Auction(props: { id: string }) {
718
+ const [auction, setAuction] = createOptimisticStore(() => liveAuction(props.id), { highBid: 0 });
719
+
720
+ const bid = action(function* (amount: number) {
721
+ setAuction((a) => { a.highBid = amount; }); // overlay
722
+ yield saveBid(props.id, amount); // the write goes up
723
+ yield until(() => auction.highBid >= amount, { timeout: 10_000 }); // hold for the echo
724
+ });
725
+
726
+ return <button onClick={() => bid(auction.highBid + 1)}>Bid {auction.highBid + 1}</button>;
727
+ }
728
+ ```
729
+
730
+ Do not `refresh(auction)` in place of `until`: on a store derived from a live source it
731
+ re-runs the derivation — the current stream is closed and `liveAuction()` opens a new
732
+ connection. That costs a reconnect per mutation and hides the flash only when the new
733
+ connection's first value already includes the write; when the read side lags the write,
734
+ the old value still flashes back before the echo. Reactive clients whose
735
+ subscriptions already carry the write when the mutation resolves (e.g. Convex) need
736
+ neither. The server side of `liveAuction` / `saveBid` is in
737
+ [Server functions](#server-functions--use-server).
738
+
635
739
  Navigation-shaped updates do **not** need core `action`. A plain setter is enough:
636
740
  reads pull the async, and downstream async computeds hold previous values until the
637
741
  new ones are ready (`isPending` / `latest`). Reach for `action` from `solid-js` only
@@ -740,9 +844,47 @@ function ThemeButton() {
740
844
 
741
845
  Pass accessors/setters/stores through context — never `value={theme()}` (a dead snapshot).
742
846
  No `useMemo` for the value object, no context splitting: the object is created once and
743
- fine-grained updates flow through the signals inside it. Context is **subtree scoping**.
744
- A true app-wide singleton (theme, session, locale) is a module-level signal/store —
745
- do not invent a root provider for that. Module state is shared across SSR requests.
847
+ fine-grained updates flow through the signals inside it.
848
+
849
+ **App-wide state is the same provider, placed at the root of `App`** — not a
850
+ module-level signal or store. Module scope is for constants and the `createContext`
851
+ call itself:
852
+
853
+ ```tsx
854
+ import { createContext, createStore, useContext, type ParentProps } from 'solid-js';
855
+
856
+ type CartItem = { id: string; quantity: number };
857
+
858
+ // WRONG under SSR — one store for every request the server ever handles
859
+ // export const [cart, setCart] = createStore({ items: [] as CartItem[] });
860
+
861
+ // CORRECT — one store per provider instance: per tab in the browser, per request on the server
862
+ function createCart() {
863
+ const [cart, setCart] = createStore({ items: [] as CartItem[] });
864
+ return { cart, setCart };
865
+ }
866
+
867
+ const CartContext = createContext<ReturnType<typeof createCart>>();
868
+
869
+ export function CartProvider(props: ParentProps) {
870
+ return <CartContext value={createCart()}>{props.children}</CartContext>;
871
+ }
872
+
873
+ export function useCart() {
874
+ return useContext(CartContext); // throws ContextNotFoundError outside CartProvider
875
+ }
876
+
877
+ // App: <CartProvider><ThemeProvider>{/* router / pages */}</ThemeProvider></CartProvider>
878
+ ```
879
+
880
+ Module state has no owner (nothing disposes it), and under `ssr: true` the server
881
+ loads the module once and serves every request from it: `createStore(value)` on the
882
+ server is the plain object, so one visitor's writes render into the next visitor's
883
+ HTML (`[SERVER_WRITE]`). A module-level store derived from a server function fails
884
+ sooner — `Cannot call server function outside of a request` while the module loads.
885
+ In a client-only build a module-level store is one per tab and works, which is why it
886
+ looks fine until `ssr: true` is turned on; the provider costs nothing extra and
887
+ survives that change.
746
888
 
747
889
  ### Refs
748
890
 
@@ -783,6 +925,32 @@ do not `return () => cleanup`. Put owned setup in a **directive factory** (like
783
925
  `ResizeObserver`, third-party widgets, and native listener options (`capture` /
784
926
  `passive`) — Solid event props do not take those options.
785
927
 
928
+ ### `onSettled` — one-time setup, restricted scope
929
+
930
+ `onSettled` is the `onMount` replacement, and its callback is a **restricted scope**:
931
+ `onCleanup` throws there (`[CLEANUP_IN_FORBIDDEN_SCOPE]`), creating a memo or effect
932
+ throws (`[PRIMITIVE_IN_FORBIDDEN_SCOPE]`), and so does `flush()` — each halts the
933
+ reactive system in dev. Return the cleanup; create effects and memos in the component
934
+ body, next to the `onSettled` call, not inside it.
935
+
936
+ ```tsx
937
+ // WRONG — the mechanical 1.x port of onMount + onCleanup
938
+ onSettled(() => {
939
+ const id = setInterval(tick, 1000);
940
+ onCleanup(() => clearInterval(id));
941
+ });
942
+
943
+ // CORRECT
944
+ onSettled(() => {
945
+ const id = setInterval(tick, 1000);
946
+ return () => clearInterval(id);
947
+ });
948
+ ```
949
+
950
+ Effect callbacks follow the same shape: return the cleanup from `apply` (an
951
+ `onCleanup` there never runs), and `flush()` inside one is a no-op
952
+ (`[FLUSH_IN_EFFECT_CALLBACK]`).
953
+
786
954
  ### Per-value SSR policy: `ssrSource`
787
955
 
788
956
  `clientOnly` splits a *component*; `ssrSource` keeps one *value* off the server,
@@ -1168,8 +1336,57 @@ plain `<a href={Router.paths.about}>` (or `useNavigate`); there is no `<A>` /
1168
1336
  `<Navigate>` / `<HashRouter>` / `<FileRoutes />`. Do not assign
1169
1337
  `window.location.href` or call `history.pushState` for in-app navigation.
1170
1338
  Session location is `useLocation` / `useParams`,
1171
- not `Router.paths`. A route `preload` result is `props.data` on the matched
1172
- component (and the factory `preload` result is the root render prop's `props.data`).
1339
+ not `Router.paths`.
1340
+
1341
+ **Route `preload` starts reads; the page reads them in a memo.** `preload` runs when
1342
+ the route matches (and on link hover), so it should kick off the query and return
1343
+ nothing; the component reads the same query through a memo that tracks the params:
1344
+
1345
+ ```tsx
1346
+ // src/data/products.ts
1347
+ import { query } from '@solidjs/router';
1348
+
1349
+ export const getProduct = query(async (id: string) => {
1350
+ const response = await fetch(`/api/products/${id}`);
1351
+ return (await response.json()) as Product;
1352
+ }, 'product');
1353
+
1354
+ // src/router.ts — defineRoute types params from the path (params.id: string)
1355
+ import { lazy } from 'solid-js';
1356
+ import { createRouter, defineRoute } from '@solidjs/router';
1357
+ import { getProduct } from './data/products';
1358
+
1359
+ export const Router = createRouter({
1360
+ routes: [
1361
+ defineRoute({
1362
+ path: '/products/:id',
1363
+ preload: ({ params }) => void getProduct(params.id),
1364
+ component: lazy(() => import('./pages/Product')),
1365
+ }),
1366
+ ],
1367
+ });
1368
+
1369
+ // src/pages/Product.tsx
1370
+ import { createMemo } from 'solid-js';
1371
+ import type { RouteProps } from '@solidjs/router';
1372
+ import { getProduct } from '../data/products';
1373
+
1374
+ export default function ProductPage(props: RouteProps<'/products/:id'>) {
1375
+ const product = createMemo(() => getProduct(props.params.id));
1376
+ return <h1>{product().name}</h1>;
1377
+ }
1378
+ ```
1379
+
1380
+ Do not return the data from `preload` and render `props.data` (the Remix-loader
1381
+ shape): `props.data` is whatever `preload` returned when the route first matched, so
1382
+ navigating `/products/mug` → `/products/bowl` keeps showing the mug, and an `async`
1383
+ preload makes it a Promise (`props.data.name` is TS2339). Return a value only for
1384
+ data that stays fixed while the route is matched. The factory `preload` on
1385
+ `createRouter` runs once per mount/request; its result is the root render prop's
1386
+ `props.data`. Plain route objects type `params` as an open record (`string |
1387
+ undefined` per key) — wrap a route in `defineRoute` when `preload` or the component
1388
+ needs `params.id: string`.
1389
+
1173
1390
  Link warming: `preload="false"` skips that link's *data* preload; `preloadLinks: false`
1174
1391
  disables automatic link preloading. Intent values are `"initial"` / `"navigate"` /
1175
1392
  `"native"` / `"preload"`. One router per app.
@@ -1326,8 +1543,42 @@ reference runs in-process (no HTTP); in the browser it is HTTP. Anything
1326
1543
  referenced only inside the `"use server"` body (db client, secrets) never
1327
1544
  reaches the client — the directive is the privacy boundary. Mutations that should
1328
1545
  revalidate: `redirect` / `reload` from `@solidjs/web` (see the server-function
1329
- guides). Return or throw a redirect — guards read `if (!session?.userId) throw
1330
- redirect("/sign-in")`. `redirect()` and `respond()` accept the same `revalidate`
1546
+ guides).
1547
+
1548
+ **`redirect()` / `reload()` are signals for an integration, not navigation.** Solid
1549
+ Router's `action` / `query` apply them (navigate, revalidate), and a no-JS form post
1550
+ follows the 302. Any other caller — a core `action`, an event handler, a plain
1551
+ `createMemo(() => listOrders())` — receives the raw `Response` as the resolved value:
1552
+ no navigation happens, and the type still claims your data. (When a mounted router's
1553
+ single-flight hook intercepts a direct POST call, it does navigate, but the call
1554
+ resolves to `null`; a direct `GET()` read always gets the `Response`.) Either way the
1555
+ caller never gets its data, so the redirect guard belongs to functions called through
1556
+ the router:
1557
+
1558
+ ```ts
1559
+ import { getRequestEvent, redirect, respond } from '@solidjs/web';
1560
+
1561
+ // called through a router action/query: the router navigates to /sign-in
1562
+ export async function listOrders() {
1563
+ 'use server';
1564
+ const userId = getRequestEvent()?.locals.userId;
1565
+ if (!userId) throw redirect('/sign-in');
1566
+ return database.orders.forUser(userId);
1567
+ }
1568
+
1569
+ // no router in the project (core action + server functions): reject instead
1570
+ export async function listMyOrders() {
1571
+ 'use server';
1572
+ const userId = getRequestEvent()?.locals.userId;
1573
+ if (!userId) throw respond(null, { status: 401 });
1574
+ return database.orders.forUser(userId);
1575
+ }
1576
+ ```
1577
+
1578
+ Without the router, fail with `throw respond(..., { status })` (the call rejects and
1579
+ `<Errored>` / the action's `catch` sees it), or return a value and navigate in the
1580
+ caller. Return a redirect when it is the successful outcome, throw it for an early
1581
+ exit. `redirect()` and `respond()` accept the same `revalidate`
1331
1582
  option as `reload()` when the mutation also changes location or returns a value;
1332
1583
  `revalidate: REVALIDATE_ALL` (`"*"`, from `@solidjs/web`) declares every cache entry
1333
1584
  stale, distinct from omitting it (the host's default) and from `[]` (nothing). After a router/server mutation do **not** follow with a client `fetch`
@@ -1367,8 +1618,42 @@ export const fetchStory = GET(async (id: number) => {
1367
1618
  });
1368
1619
  ```
1369
1620
 
1621
+ A value that changes over time is `live(GET(fn))` — **`live()` outermost**: `GET()`
1622
+ chooses the read transport and `live()` wraps the call behavior around it. `live()`
1623
+ does not imply `GET()`; without it the source streams over POST, which is fine when a
1624
+ cacheable URL is of no use.
1625
+
1626
+ ```ts
1627
+ // src/data/auction.ts
1628
+ import { GET, live } from '@solidjs/web/server-functions';
1629
+
1630
+ export const liveAuction = live(
1631
+ GET(async function* (id: string) {
1632
+ 'use server';
1633
+ yield await db.auctions.find(id); // current state first, from storage
1634
+ for await (const _change of db.auctions.subscribe(id)) {
1635
+ yield await db.auctions.find(id);
1636
+ }
1637
+ }),
1638
+ );
1639
+
1640
+ export async function saveBid(id: string, amount: number) {
1641
+ 'use server';
1642
+ await db.bids.place(id, amount);
1643
+ }
1644
+ ```
1645
+
1646
+ A plain `async function*` server function (no `live()`) is a different tool: an
1647
+ **event sequence** on one connection, consumed with `for await` and accumulated. When
1648
+ that connection drops, the iteration ends with an error and nothing reopens it; ending
1649
+ the iteration in the browser aborts the request and fires the server's
1650
+ `request.signal`. Use it for an order's status history; use `live()` when each value
1651
+ replaces the previous one.
1652
+
1370
1653
  `live()` yields **current state** (each yield replaces the last); do not treat
1371
- it as an append-only event log. Until the first yield it is unsettled
1654
+ it as an append-only event log. A reconnect calls the function again and its first
1655
+ yield replaces the stale answer, so read that first value from the state mutations write
1656
+ to (database, shared server state) — not from generator locals or per-connection memory. Until the first yield it is unsettled
1372
1657
  (`<Loading>`), same as a Promise; later yields are updates — the fallback does
1373
1658
  not return.
1374
1659
  Wire status is a side channel on the **iterable the `live()` call returns**,
@@ -1410,10 +1695,22 @@ Express `app.use`. Server functions that return components
1410
1695
  (`serverFunctions: { components: true }`) are experimental preview — do not use
1411
1696
  them unless the project already has that flag on.
1412
1697
 
1413
- Unscripted forms: `<form method="post" action={createTodo.url}>` (the reference
1414
- `.url`), not a client `preventDefault` + `fetch`, and not a hand-built
1415
- `/_server/` URL. GET forms (`method="get" action={search.url}`) are only for
1416
- idempotent search — do not use a GET form for a mutation.
1698
+ Unscripted (no-JS) POST forms go through a router action —
1699
+ `const save = action(createTodo, "create-todo")` from `@solidjs/router`, then
1700
+ `<form action={save} method="post">` — not a client `preventDefault` + `fetch`, not a
1701
+ hand-built `/_server/` URL, and not `action={createTodo.url}`: a bare `"use server"`
1702
+ function keeps its declared function type, so `.url` is a type error (TS2339) even
1703
+ though it exists at runtime (and `serverFunctionActionUrl(createTodo)` is TS2345).
1704
+ The post arrives as one `FormData` argument, so the function behind `action={save}`
1705
+ takes `(form: FormData)` and parses and validates the fields inside; any other
1706
+ signature makes `action={save}` a type error (TS2322). Bind leading JSON-safe
1707
+ arguments with `action={save.with(id)}` for `(id: string, form: FormData)`; a function
1708
+ that already has a typed signature gets a separate `FormData` server function that
1709
+ parses and calls it.
1710
+ `.url` is typed only on `GET()` / `live()` references, which makes it the address of a
1711
+ `method="get"` search form (`action={searchProducts.url}`; the function receives the
1712
+ fields as `URLSearchParams`). GET forms are only for idempotent search — do not use a
1713
+ GET form for a mutation.
1417
1714
 
1418
1715
  ### Document head — no MetaProvider
1419
1716
 
@@ -1449,8 +1746,11 @@ how to reuse. Prefer the form on the right.
1449
1746
  | `action(async function* () { await save(); setX(v); })` | `yield save()` or `await save(); yield; setX(v)` |
1450
1747
  | `action(async (item) => { ... })` with core `action` from `solid-js` | `action(function* (item) { ...; yield ...; ... })` — only a generator re-enters the transaction (`solid2-kit check` flags this). Router `action` from `@solidjs/router` is the one that takes `async (form) => ...` |
1451
1748
  | `todos()` on a store | `todos.length` / `todo.title` (property reads) |
1749
+ | `todos.push(x)` / `state.count++` / `todo.done = !todo.done` on the store itself | `setTodos((draft) => { draft.push(x); })` — writes to the store proxy outside a setter are silently ignored (no error, no warning, tsc passes) |
1750
+ | `createStore({ selected: new Set<string>() })` + `draft.selected.add(id)` | `selected: {} as Record<string, true>` + `draft.selected[id] = true` / `delete draft.selected[id]`, or `draft.selected = new Set([...draft.selected, id])` — `Map` / `Set` / `Date` are stored as-is, not tracked |
1452
1751
  | `draft.todos = fresh` (wholesale fresh-tree assignment) | `reconcile(fresh, "id")`, or function-form `createStore` / `createProjection` (auto-keyed by `"id"`). Removal via `setTodos((t) => t.filter(...))` is fine — survivors keep identity |
1453
- | `createEffect(() => user, (u) => log(u.name))` | `createEffect(() => user.name, (name) => log(name))` |
1752
+ | `createEffect(() => user, (u) => { log(u.name); })` | `createEffect(() => user.name, (name) => { log(name); })` |
1753
+ | `createEffect(() => title(), (t) => (document.title = t))` / `(v) => setX(v)` | `(t) => { document.title = t; }` — apply returns a cleanup or nothing; any other return value halts reactivity. An apply that only calls a local setter is rule 4 even with braces (`solid2-kit check` flags it): use a (writable) derivation |
1454
1754
  | `<Loading fallback={<PageSkeleton />}>{/* header + data */}</Loading>` | wrap only the data slot; chrome stays outside |
1455
1755
  | `<Loading on={id} fallback={...}>` (the accessor) | `on={id()}` — a value, so identity changes can show fallback |
1456
1756
  | `setCount(count() + 1)` when writes can batch | `setCount((c) => c + 1)` |
@@ -1469,7 +1769,10 @@ how to reuse. Prefer the form on the right.
1469
1769
  | `throw new Error("expired")` from a server function (prod) | `throw markSafeError(...)` or `throw respond(body, { status })` |
1470
1770
  | `const r = await api.x(); if (!r.ok) ...` result-object checks at every read | throw unusable responses; contain each region with `<Errored>` |
1471
1771
  | `GET(async (id) => { "use server"; await db.delete(id) })` | mutations stay on POST; `GET()` is for idempotent reads |
1472
- | `live()` yields as an event log to append | each yield **replaces** the current answer; yield current state first |
1772
+ | `live()` yields as an event log to append | each yield **replaces** the current answer; yield current state first, read from the state mutations write to — a reconnect calls the function again, so generator locals or per-connection memory are not the source |
1773
+ | `GET(live(async function* () { ... }))` | `live(GET(async function* () { ... }))` — `live()` outermost; `live()` alone streams over POST |
1774
+ | a plain `async function*` `"use server"` stream for a value that must survive a dropped connection | `live()` — a plain stream is an event sequence on one connection (`for await`, accumulate); nothing reopens it when it drops |
1775
+ | `yield saveBid(...)` then `refresh(auction)` on a store derived from `live()` | `yield saveBid(...); yield until(() => auction.highBid >= amount, { timeout })` — `live()` is outside revalidation/single-flight; `refresh` re-runs the derivation (closes the stream, calls the source again): a reconnect per mutation, and the old value still flashes back whenever the new first value predates the write |
1473
1776
  | `<article innerHTML={html()}>…children…</article>` | `innerHTML` **or** children, not both |
1474
1777
  | `fallback={(error) => <p>{error.message}</p>}` | `error` is an accessor: `error().message` (or `String(error())`) |
1475
1778
  | `import { action } from "solid-js"` on a `<form>` | `import { action } from "@solidjs/router"` + `<form action={save} method="post">` (core `action` is a generator transaction, not a form URL) |
@@ -1484,15 +1787,17 @@ how to reuse. Prefer the form on the right.
1484
1787
  | `clientOnly` split (or `isServer` branch) for one browser-only *value* | `createMemo(() => localStorage.getItem(k), { ssrSource: "client" })` — `"hybrid"` when server data mixes with client signals |
1485
1788
  | `await flush()` / `flush()` to wait on a pending memo | `await resolve(() => value())` (or Testing Library async queries); `flush()` only drains staged *sync* writes |
1486
1789
  | `action(function* () { setX(v); })` around a navigation-shaped setter | plain setter; async computeds hold previous values. Core `action` only when writes happen *after* async work |
1487
- | `createContext` + a root provider for a true app singleton | module-level signal/store (SSR: shared across requests). Context is subtree scoping |
1790
+ | `export const [cart, setCart] = createStore(...)` for app-wide state | `createCart()` called by a `CartProvider` at the root of `App`, read with `useCart()` over `createContext<T>()` — module state has no owner and, under SSR, one instance serves every request (`[SERVER_WRITE]`). Module scope is for constants and `createContext` |
1488
1791
  | `import { GET } from "@solidjs/start"` / `createAsync` / `useSubmission` / `<FileRoutes />` / `"use client"` | `GET` from `@solidjs/web/server-functions`; `createMemo(() => getUser(id()))`; `useSubmissions`; `fileRoutes(pageRoutes)`; Solid has no `"use client"` |
1489
1792
  | `<Show keyed when={user()}>` by default | default `Show` (keeps children mounted); `keyed` only when internal state must reset |
1490
1793
  | overlapping truthy `<Match>`es all expected to render | first truthy `<Match>` wins; later matches are skipped |
1491
1794
  | missing `<HydrationScript />` / one script for many roots | once, before app markup, when the app owns the document; distinct `renderId` per extra root |
1492
- | `Router.paths` as the current URL; calling `preload()` and using the return as props | `useLocation` / `useParams`; factory/route `preload` result is `props.data` |
1795
+ | `Router.paths` as the current URL | `useLocation` / `useParams` |
1796
+ | `preload: async ({ params }) => getProduct(params.id)` + `props.data.name` in the page | `preload: ({ params }) => void getProduct(params.id)` + `createMemo(() => getProduct(props.params.id))` — `props.data` is captured once when the route matches (stale on param change; a Promise for async preloads) |
1493
1797
  | `query.get(key)` with no guaranteed entry; `query.set(key, promise)` | `query.get` throws if missing; `query.set` does not accept a promise |
1494
1798
  | `onCleanup(() => ...)` in a component body | `onSettled(() => { ...; return cleanup })` — `onCleanup` is for custom primitives |
1495
- | `onSettled` with reactive reads expecting re-runs | each call is a **single** fire; reads are untracked. Ongoing work is `createEffect` |
1799
+ | `onSettled` with reactive reads expecting re-runs | each call is a **single** fire; reads are untracked. Ongoing work is `createEffect`, created in the component body |
1800
+ | `onSettled(() => { const id = setInterval(tick, 1000); onCleanup(() => clearInterval(id)); })` | `onSettled(() => { const id = setInterval(tick, 1000); return () => clearInterval(id); })` — `onCleanup`, `createMemo` / `createEffect`, and `flush()` throw inside the callback |
1496
1801
  | `<Router>` nested inside `<Router>` | one instance; nest `children` arrays (or a lazy children thunk) |
1497
1802
  | `const View = tab() ? A : B; return <View />` | `const View = dynamic(() => (tab() ? A : B))` — the ternary at setup freezes the choice |
1498
1803
  | `<For each={resolved()}>` after `children()` | `{resolved.toArray()}` — resolved children are not a reactive data list |
@@ -1503,7 +1808,7 @@ how to reuse. Prefer the form on the right.
1503
1808
  | `createMemo(async fn, { loadingValue })` / `{ seedLoadingValue: true }` as the default first-flight UI | `<Loading>` for first flight; those options are escape hatches (store projections use `seedLoadingValue`) |
1504
1809
  | `setSubmitted(true)` + an effect that watches it | do the work in the handler or an `action` |
1505
1810
  | `findUser(userId)` with the id from the client as identity | `getRequestEvent()!.locals.userId` |
1506
- | `action={"/_server/" + id}` in app JSX | `action={createTodo.url}` or a router `action` |
1811
+ | `action={"/_server/" + id}` / `action={createTodo.url}` on a POST form | a router action: `const save = action(createTodo, "create-todo")` + `<form action={save} method="post">` over a `(form: FormData)` server function (other signatures are TS2322) — `.url` on a bare `"use server"` function is TS2339; it is typed only on `GET()` / `live()` references (GET search forms) |
1507
1812
  | `<form method="get" action={update.url}>` for a mutation | GET forms only for idempotent search; mutations are POST |
1508
1813
  | `window.location.href = ...` / `history.pushState` | `useNavigate()` or `<a href={Router.paths...}>` |
1509
1814
  | `class={{ active: location.pathname === "/about" }}` hand-rolled link state | CSS on automatic `aria-current` / `data-active` / `data-pending`; `useLinkState` in JSX, `useIsRouting()` for progress bars |
@@ -1518,7 +1823,7 @@ how to reuse. Prefer the form on the right.
1518
1823
  | `class={{ pending: isPending(id) }}` flashing on every navigation | same class + a short CSS `transition-delay` so only slow swaps show |
1519
1824
  | `<Comments />` nested under `{story().title}` assumed to waterfall | both mount immediately and fetch in parallel if the child reads an already-known id; a real waterfall is `createMemo(() => fetchAuthor(story().authorId))` or `storyId={props.story.id}` |
1520
1825
  | `<Errored>` as a terminal ErrorBoundary; `reset={() => location.reload()}` | the boundary heals when the source succeeds again; `reset` retries collected *sources*, not a UI remount |
1521
- | `<Errored>` around a list catching a toggle/save failure | catch expected mutation failures in the `action`; keep row errors in a map the projection folds in (survives overlay discard) |
1826
+ | `<Errored>` around a list expected to catch a toggle/save failure | an error thrown out of an `action` never reaches `<Errored>` — it reverts the overlay and rejects the call's promise. Catch expected failures in the `action` (row errors in a map the projection folds in, which survives overlay discard; otherwise a toast) and `.catch` the call in the handler |
1522
1827
  | `yield { ...tick, connected: true }` from `live()` | yield the value; wire status is `source.onstatus` (`"connected"` / `"reconnecting"` / `"closed"`) |
1523
1828
  | sibling `<Loading>` fallbacks stacking as popcorn | `<Reveal collapsed>` (sequential default) suppresses tail skeletons past the frontier |
1524
1829
  | in-memory cache around a GET server function | `return respond(value, { headers: { "cache-control": "..." } })` — metadata rides HTTP, the value rides the graph |
@@ -1530,6 +1835,7 @@ how to reuse. Prefer the form on the right.
1530
1835
  | a held write with no feedback anywhere | pair it with `isPending()` / `latest()` / an optimistic value / `affects()` — otherwise dev + attribution reports `[SILENT_HOLD]` |
1531
1836
  | `refresh(getUser.key)` / `revalidate(user)` / `return refresh()` from `"use server"` | three APIs: core `refresh(source)` reruns a reactive source; router `revalidate(getUser.key)` invalidates the query cache; `return reload({ revalidate: "todos" })` asks the integration to refresh cached data |
1532
1837
  | `return redirect(...)` as the only redirect shape | guards `throw redirect("/sign-in")`; `redirect()` / `respond()` take `revalidate` too when the mutation also moves or returns a value |
1838
+ | `throw redirect("/sign-in")` / `return reload(...)` in a function called directly (core `action`, handler, plain memo) | only Solid Router `action` / `query` (and no-JS form posts) apply them — a direct caller resolves to the raw `Response` (or `null` when the router's single-flight hook intercepts a POST call), typed as your data. Without the router: `throw respond(null, { status: 401 })` or return a value and navigate in the caller |
1533
1839
  | `submission.clear()` from an effect | on dismiss/resubmit only — from an effect, errors flash and vanish |
1534
1840
  | `revalidate(liveSource)` | `live()` updates through the open stream; do not revalidate it |
1535
1841
  | `refresh(user)` without `affects` when the reload should look pending | pair `affects(user)` with `refresh(user)` — a bare `refresh` re-asks quietly |
@@ -1564,7 +1870,8 @@ how to reuse. Prefer the form on the right.
1564
1870
  | `createRenderEffect` / `createTrackedEffect` as the default effect | two-phase `createEffect(compute, apply)` |
1565
1871
  | `createEffect(() => source(), () => setError(null))` to reset a signal | `createSignal(() => { source(); return null; })` — the writable derivation resets on change; a sole-setter apply is rule 4 |
1566
1872
  | a "server-safe" initial value adopted after mount (React hydration-mismatch fix) | client mode mounts with `render()` into an empty body — no hydration, no mismatch; read `localStorage` / `matchMedia` directly at signal creation |
1567
- | rewrite `App.tsx` with loading/error branches, or snapshot/restore, when the list moves to the server | same `setTodos`; wrap mutations in `action`; swap `createOptimisticStore` + a server-functions file. The overlay is discarded; the confirmed store is the truth |
1873
+ | rewrite `App.tsx` with loading/error branches, or snapshot/restore, when the list moves to the server | same `setTodos`; wrap mutations in `action`; swap in function-form `createOptimisticStore(() => api.list(), [])` + a server-functions file. The overlay is discarded; the confirmed store is the truth |
1874
+ | `createOptimisticStore<Todo[]>([])` holding the list, saved rows written after `yield` | `createOptimisticStore(() => api.list(), [])` + `refresh(todos)` (or a function form over a separate `createStore`) — the value form is a pure overlay: action writes vanish on settle |
1568
1875
  | disable an optimistic row until ack, or mutex rapid clicks / freeze unrelated writes | keep the control enabled; `action` is the transaction. An in-flight action does not freeze other writes. Failed-action replay is optional |
1569
1876
  | tRPC / RPC type-gen around `"use server"` | TypeScript does not cross HTTP — validate inside the function. Locals only the body reads (db, secrets) stay off the client |
1570
1877
  | `src/routes/api/todos.ts` (or a Next `route.ts`) wrapping `db.todos.insert` | `"use server"` *is* the RPC. API routes are for real HTTP endpoints (webhooks), not the app's own mutations |
@@ -1717,16 +2024,19 @@ always-applied rules installed alongside this skill.
1717
2024
  `loading`/`error` signals or `=== undefined` readiness branches. First load is
1718
2025
  `<Loading>`; refetch indicator is `isPending(user)` (the accessor, not `user()`)
1719
2026
  or `isPending(selectedId)` when the write is the question. `<Errored>` heals
1720
- when the source succeeds; `reset` retries sources, not a UI remount. Per-row
1721
- mutation failures stay in the action/projection, not in `<Errored>` around
1722
- the list. Error monitoring goes through `configureClientErrors` /
2027
+ when the source succeeds; `reset` retries sources, not a UI remount. Mutation
2028
+ failures never reach `<Errored>` (an escaping error reverts the overlay and
2029
+ rejects the call): catch them in the action/projection, and `.catch` the call
2030
+ in the handler. Error monitoring goes through `configureClientErrors` /
1723
2031
  `configureServerErrors` (or `render(..., { onError })`), never a side effect in
1724
2032
  the fallback. Core `refresh(source)`, router `revalidate(key)`, and `return reload(...)` are
1725
2033
  different APIs — do not mix them. Client → server is additive (same
1726
2034
  setters + `action` + server functions): do not rewrite App with
1727
2035
  loading/error branches, disable optimistic rows until ack, or refetch in
1728
2036
  `hydrate` / `onSettled`. HTTP does not enforce TypeScript — validate
1729
- inside `"use server"`; do not invent tRPC. `live()` owns the client connection.
2037
+ inside `"use server"`; do not invent tRPC. `live()` owns the client connection,
2038
+ is declared `live(GET(fn))`, and a mutation on a live-read value holds with
2039
+ `yield until(...)` for the echo instead of `refresh()`.
1730
2040
  A server bundle uses `handleRequest` from `dist/server/server.js`; client-only start
1731
2041
  is static `dist/client`. Do not delay `renderToStream` for visual order
1732
2042
  (`<Reveal>` does).
@@ -1742,7 +2052,10 @@ always-applied rules installed alongside this skill.
1742
2052
  component choice with `dynamic()`. No `React.lazy`, no effects inside ref callbacks.
1743
2053
  - [ ] Browser-only code uses `isServer` / `clientOnly` (components) / `ssrSource`
1744
2054
  (values), not `typeof window`.
1745
- Component teardown is `onSettled` (return cleanup), not `onCleanup`.
2055
+ Component teardown is `onSettled` (return cleanup), not `onCleanup`; nothing
2056
+ inside the `onSettled` callback calls `onCleanup`, `flush()`, or creates a
2057
+ memo/effect. Effect `apply` functions have block bodies and return only a
2058
+ cleanup or nothing.
1746
2059
  - [ ] Dev console shows no diagnostics: reads in tracking scopes (no
1747
2060
  `[STRICT_READ_UNTRACKED]`), store setters synchronous and never called at
1748
2061
  body level (no `[ASYNC_STORE_SETTER]` / `[REACTIVE_WRITE_IN_OWNED_SCOPE]`), held
@@ -66,6 +66,7 @@ read these before writing route JSX.
66
66
  ## Guides and migration
67
67
 
68
68
  - https://v2-rebuild--solid-docs-v2.netlify.app/guides/avoid-unnecessary-effects.md
69
+ - https://v2-rebuild--solid-docs-v2.netlify.app/guides/state-management.md
69
70
  - https://v2-rebuild--solid-docs-v2.netlify.app/guides/debugging-reactivity.md
70
71
  - https://v2-rebuild--solid-docs-v2.netlify.app/guides/performance.md
71
72
  - https://v2-rebuild--solid-docs-v2.netlify.app/guides/observability.md
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid2-agent-kit",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Teach AI coding agents (Cursor, Claude Code) to write correct Solid 2.0 instead of React or Solid 1.x — always-applied rules, an agent skill, and a mechanical pattern gate.",
5
5
  "type": "module",
6
6
  "license": "MIT",