solid2-agent-kit 0.14.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,8 +26,26 @@ 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 —
@@ -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`. 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.
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
@@ -309,15 +345,25 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
309
345
  `revalidate(getUser.key)`. Do not wrap mutations in `query` (an undeclared server
310
346
  function becomes GET). `Router.paths` is not the current location (`useLocation` /
311
347
  `useParams`); search params via `useSearchParams` (merging setter; typed with a route
312
- `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 —
313
355
  Solid Router does not support nested `<Router>` / nested `createRouter`.
314
356
  In-app navigation is `useNavigate` or `<a href={Router.paths...}>`, not
315
357
  `window.location` / `history.pushState`. Link state is automatic
316
358
  (`aria-current` / `data-active` / `data-pending` + CSS; `useLinkState` /
317
359
  `useIsRouting` in JSX) — never hand-rolled `location.pathname` comparisons. Trusted identity is
318
- `getRequestEvent()`, never a caller-supplied user id. Unscripted forms use
319
- the function `.url` (or a router `action`), not a hand-built `/_server/`
320
- 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
321
367
  `<Router url={request.url}>` (or rely on the request event). Secrets belong in
322
368
  `virtual:env/server`, never the `client` env map / `import.meta.env`. Document head is
323
369
  `<Title>` / `<Meta>` from `@solidjs/meta` with **no** `MetaProvider`. A static
@@ -330,8 +376,15 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
330
376
  fallback printing `err().message` shows `"Internal Server Error"` in a production
331
377
  server render) — so intentional client-facing failures are `markSafeError(...)` or
332
378
  `throw respond(body, { status })`. Reads are function calls too (`await getUser(id)`
333
- on a `GET()`-declared read; the method mirrors the operation). Return or throw a
334
- 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()` /
335
388
  `respond()` take `revalidate` like `reload()`. Cache GET reads with
336
389
  `Cache-Control` on that response (the default is `no-store`), not a hand-rolled
337
390
  Solid cache. HTTP does not enforce TypeScript —
@@ -395,7 +448,7 @@ directory (default `src/`).
395
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"` |
396
449
  | `createResource` | async `createMemo` + `<Loading>`/`<Errored>`; `refresh()`, `latest()`, `isPending()` |
397
450
  | `createEffect(fn)` (one arg), `on(...)` | `createEffect(compute, apply)`; deps belong in `compute` |
398
- | `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 |
399
452
  | `batch(...)` | delete it — writes auto-batch; `flush()` only to observe synchronously |
400
453
  | `<Suspense>`, `<ErrorBoundary>`, `<SuspenseList>` | `<Loading>`, `<Errored>` (fallback gets an error *accessor*), `<Reveal>` |
401
454
  | `<Index>` | `<For keyed={false}>` |
@@ -403,7 +456,7 @@ directory (default `src/`).
403
456
  | `setState("a", "b", value)` path setters, `produce(...)` | draft setter: `setState(draft => { ... })` |
404
457
  | `mergeProps` / `splitProps` / `unwrap` | `merge` / `omit` / `snapshot` |
405
458
  | `classList={...}` | `class` object/array form |
406
- | `createMutable`, `modifyMutable` | `createStore` + draft setters |
459
+ | `createMutable`, `modifyMutable` | `createStore` + draft setters (direct writes to the store are silently ignored) |
407
460
  | `createComputed`, `createSelector`, `createDeferred` | `createMemo`, `createProjection`, external scheduling |
408
461
  | `startTransition`, `useTransition` | automatic held updates + `isPending` |
409
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
@@ -772,9 +844,47 @@ function ThemeButton() {
772
844
 
773
845
  Pass accessors/setters/stores through context — never `value={theme()}` (a dead snapshot).
774
846
  No `useMemo` for the value object, no context splitting: the object is created once and
775
- fine-grained updates flow through the signals inside it. Context is **subtree scoping**.
776
- A true app-wide singleton (theme, session, locale) is a module-level signal/store —
777
- 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.
778
888
 
779
889
  ### Refs
780
890
 
@@ -815,6 +925,32 @@ do not `return () => cleanup`. Put owned setup in a **directive factory** (like
815
925
  `ResizeObserver`, third-party widgets, and native listener options (`capture` /
816
926
  `passive`) — Solid event props do not take those options.
817
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
+
818
954
  ### Per-value SSR policy: `ssrSource`
819
955
 
820
956
  `clientOnly` splits a *component*; `ssrSource` keeps one *value* off the server,
@@ -1200,8 +1336,57 @@ plain `<a href={Router.paths.about}>` (or `useNavigate`); there is no `<A>` /
1200
1336
  `<Navigate>` / `<HashRouter>` / `<FileRoutes />`. Do not assign
1201
1337
  `window.location.href` or call `history.pushState` for in-app navigation.
1202
1338
  Session location is `useLocation` / `useParams`,
1203
- not `Router.paths`. A route `preload` result is `props.data` on the matched
1204
- 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
+
1205
1390
  Link warming: `preload="false"` skips that link's *data* preload; `preloadLinks: false`
1206
1391
  disables automatic link preloading. Intent values are `"initial"` / `"navigate"` /
1207
1392
  `"native"` / `"preload"`. One router per app.
@@ -1358,8 +1543,42 @@ reference runs in-process (no HTTP); in the browser it is HTTP. Anything
1358
1543
  referenced only inside the `"use server"` body (db client, secrets) never
1359
1544
  reaches the client — the directive is the privacy boundary. Mutations that should
1360
1545
  revalidate: `redirect` / `reload` from `@solidjs/web` (see the server-function
1361
- guides). Return or throw a redirect — guards read `if (!session?.userId) throw
1362
- 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`
1363
1582
  option as `reload()` when the mutation also changes location or returns a value;
1364
1583
  `revalidate: REVALIDATE_ALL` (`"*"`, from `@solidjs/web`) declares every cache entry
1365
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`
@@ -1476,10 +1695,22 @@ Express `app.use`. Server functions that return components
1476
1695
  (`serverFunctions: { components: true }`) are experimental preview — do not use
1477
1696
  them unless the project already has that flag on.
1478
1697
 
1479
- Unscripted forms: `<form method="post" action={createTodo.url}>` (the reference
1480
- `.url`), not a client `preventDefault` + `fetch`, and not a hand-built
1481
- `/_server/` URL. GET forms (`method="get" action={search.url}`) are only for
1482
- 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.
1483
1714
 
1484
1715
  ### Document head — no MetaProvider
1485
1716
 
@@ -1515,8 +1746,11 @@ how to reuse. Prefer the form on the right.
1515
1746
  | `action(async function* () { await save(); setX(v); })` | `yield save()` or `await save(); yield; setX(v)` |
1516
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) => ...` |
1517
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 |
1518
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 |
1519
- | `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 |
1520
1754
  | `<Loading fallback={<PageSkeleton />}>{/* header + data */}</Loading>` | wrap only the data slot; chrome stays outside |
1521
1755
  | `<Loading on={id} fallback={...}>` (the accessor) | `on={id()}` — a value, so identity changes can show fallback |
1522
1756
  | `setCount(count() + 1)` when writes can batch | `setCount((c) => c + 1)` |
@@ -1553,15 +1787,17 @@ how to reuse. Prefer the form on the right.
1553
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 |
1554
1788
  | `await flush()` / `flush()` to wait on a pending memo | `await resolve(() => value())` (or Testing Library async queries); `flush()` only drains staged *sync* writes |
1555
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 |
1556
- | `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` |
1557
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"` |
1558
1792
  | `<Show keyed when={user()}>` by default | default `Show` (keeps children mounted); `keyed` only when internal state must reset |
1559
1793
  | overlapping truthy `<Match>`es all expected to render | first truthy `<Match>` wins; later matches are skipped |
1560
1794
  | missing `<HydrationScript />` / one script for many roots | once, before app markup, when the app owns the document; distinct `renderId` per extra root |
1561
- | `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) |
1562
1797
  | `query.get(key)` with no guaranteed entry; `query.set(key, promise)` | `query.get` throws if missing; `query.set` does not accept a promise |
1563
1798
  | `onCleanup(() => ...)` in a component body | `onSettled(() => { ...; return cleanup })` — `onCleanup` is for custom primitives |
1564
- | `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 |
1565
1801
  | `<Router>` nested inside `<Router>` | one instance; nest `children` arrays (or a lazy children thunk) |
1566
1802
  | `const View = tab() ? A : B; return <View />` | `const View = dynamic(() => (tab() ? A : B))` — the ternary at setup freezes the choice |
1567
1803
  | `<For each={resolved()}>` after `children()` | `{resolved.toArray()}` — resolved children are not a reactive data list |
@@ -1572,7 +1808,7 @@ how to reuse. Prefer the form on the right.
1572
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`) |
1573
1809
  | `setSubmitted(true)` + an effect that watches it | do the work in the handler or an `action` |
1574
1810
  | `findUser(userId)` with the id from the client as identity | `getRequestEvent()!.locals.userId` |
1575
- | `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) |
1576
1812
  | `<form method="get" action={update.url}>` for a mutation | GET forms only for idempotent search; mutations are POST |
1577
1813
  | `window.location.href = ...` / `history.pushState` | `useNavigate()` or `<a href={Router.paths...}>` |
1578
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 |
@@ -1587,7 +1823,7 @@ how to reuse. Prefer the form on the right.
1587
1823
  | `class={{ pending: isPending(id) }}` flashing on every navigation | same class + a short CSS `transition-delay` so only slow swaps show |
1588
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}` |
1589
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 |
1590
- | `<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 |
1591
1827
  | `yield { ...tick, connected: true }` from `live()` | yield the value; wire status is `source.onstatus` (`"connected"` / `"reconnecting"` / `"closed"`) |
1592
1828
  | sibling `<Loading>` fallbacks stacking as popcorn | `<Reveal collapsed>` (sequential default) suppresses tail skeletons past the frontier |
1593
1829
  | in-memory cache around a GET server function | `return respond(value, { headers: { "cache-control": "..." } })` — metadata rides HTTP, the value rides the graph |
@@ -1599,6 +1835,7 @@ how to reuse. Prefer the form on the right.
1599
1835
  | a held write with no feedback anywhere | pair it with `isPending()` / `latest()` / an optimistic value / `affects()` — otherwise dev + attribution reports `[SILENT_HOLD]` |
1600
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 |
1601
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 |
1602
1839
  | `submission.clear()` from an effect | on dismiss/resubmit only — from an effect, errors flash and vanish |
1603
1840
  | `revalidate(liveSource)` | `live()` updates through the open stream; do not revalidate it |
1604
1841
  | `refresh(user)` without `affects` when the reload should look pending | pair `affects(user)` with `refresh(user)` — a bare `refresh` re-asks quietly |
@@ -1633,7 +1870,8 @@ how to reuse. Prefer the form on the right.
1633
1870
  | `createRenderEffect` / `createTrackedEffect` as the default effect | two-phase `createEffect(compute, apply)` |
1634
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 |
1635
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 |
1636
- | 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 |
1637
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 |
1638
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 |
1639
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 |
@@ -1786,9 +2024,10 @@ always-applied rules installed alongside this skill.
1786
2024
  `loading`/`error` signals or `=== undefined` readiness branches. First load is
1787
2025
  `<Loading>`; refetch indicator is `isPending(user)` (the accessor, not `user()`)
1788
2026
  or `isPending(selectedId)` when the write is the question. `<Errored>` heals
1789
- when the source succeeds; `reset` retries sources, not a UI remount. Per-row
1790
- mutation failures stay in the action/projection, not in `<Errored>` around
1791
- 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` /
1792
2031
  `configureServerErrors` (or `render(..., { onError })`), never a side effect in
1793
2032
  the fallback. Core `refresh(source)`, router `revalidate(key)`, and `return reload(...)` are
1794
2033
  different APIs — do not mix them. Client → server is additive (same
@@ -1813,7 +2052,10 @@ always-applied rules installed alongside this skill.
1813
2052
  component choice with `dynamic()`. No `React.lazy`, no effects inside ref callbacks.
1814
2053
  - [ ] Browser-only code uses `isServer` / `clientOnly` (components) / `ssrSource`
1815
2054
  (values), not `typeof window`.
1816
- 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.
1817
2059
  - [ ] Dev console shows no diagnostics: reads in tracking scopes (no
1818
2060
  `[STRICT_READ_UNTRACKED]`), store setters synchronous and never called at
1819
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.14.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",