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 +1 -1
- package/bin/solid2-kit.mjs +115 -11
- package/files/commands/solid2-review.md +20 -2
- package/files/shared/agents-section.md +1 -1
- package/files/shared/rules-body.md +71 -18
- package/files/skills/solid-2/SKILL.md +269 -27
- package/files/skills/solid-2/references/official-docs.md +1 -0
- package/package.json +1 -1
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`
|
|
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
|
package/bin/solid2-kit.mjs
CHANGED
|
@@ -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>] [
|
|
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())
|
|
723
|
-
|
|
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
|
|
779
|
+
const pathArgs = positionalArgs();
|
|
749
780
|
|
|
750
781
|
let files;
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
|
1083
|
-
|
|
1084
|
-
|
|
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>] [
|
|
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
|
|
30
|
-
failures
|
|
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.
|
|
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.
|
|
147
|
-
|
|
148
|
-
|
|
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.
|
|
194
|
-
|
|
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
|
|
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)
|
|
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`
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
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).
|
|
334
|
-
|
|
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`
|
|
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
|
-
|
|
|
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
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
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.
|
|
776
|
-
|
|
777
|
-
|
|
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`.
|
|
1204
|
-
|
|
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).
|
|
1362
|
-
|
|
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
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
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
|
-
| `
|
|
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
|
|
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}`
|
|
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
|
|
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.
|
|
1790
|
-
|
|
1791
|
-
the
|
|
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.
|
|
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",
|