solid2-agent-kit 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/solid2-kit.mjs +115 -11
- package/files/commands/solid2-review.md +23 -2
- package/files/shared/agents-section.md +1 -1
- package/files/shared/rules-body.md +85 -22
- package/files/skills/solid-2/SKILL.md +343 -30
- 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,14 +26,35 @@ Steps:
|
|
|
26
26
|
`Promise<User>` props;
|
|
27
27
|
- `latest(selectedId)` as the default highlight (hold is the default);
|
|
28
28
|
`isPending` treated as a global spinner instead of a per-expression question;
|
|
29
|
-
- treating `<Errored>` as a terminal ErrorBoundary, or
|
|
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 —
|
|
34
52
|
`[ASYNC_STORE_SETTER]`), store setters called at component-body top level, and
|
|
35
53
|
`latest()` used as a null-safe read of an unsettled source;
|
|
36
54
|
- nested fetches assumed to waterfall, or `<Loading>` lifted along with a lifted fetch;
|
|
55
|
+
- mutations on a value read through `live()` that settle on `yield save()` alone or
|
|
56
|
+
`refresh()` the live-derived store, instead of `yield until(predicate, { timeout })`
|
|
57
|
+
for the stream's echo (the old value flashes back when the overlay drops);
|
|
37
58
|
- rewriting App with loading/error branches, or snapshot/restore, when wrapping
|
|
38
59
|
a client store in server functions; disabling optimistic rows until ack;
|
|
39
60
|
- tRPC / type-gen around `"use server"`, a client `fetch` after a mutation,
|
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
- After editing TSX, run the `solid2-kit check` mechanical gate — it fails on React/Solid 1.x tokens and props destructuring. Use the project's `lint:solid` script if one exists; otherwise `npx solid2-agent-kit check` (with the kit as a devDependency) or `npx github:lightsound/solid2-agent-kit check`. An edit-time hook may also run this gate automatically after each file edit — treat its findings as errors to fix immediately, not warnings. Do not install `eslint-plugin-solid` (built for Solid 1.x; misreads Solid 2 idioms).
|
|
6
6
|
- After editing `package.json`, `tsconfig*.json`, or root config files (Vite, ESLint), run `solid2-kit doctor` — it fails on React/Solid 1.x wiring: `react`/`vite-plugin-solid`/`eslint-plugin-solid`/SolidStart dependencies, 1.x `solid-js` ranges, and `jsx`/`jsxImportSource` values other than `preserve`/`@solidjs/web`.
|
|
7
7
|
- The kit's guidance and gate files (`.cursor/rules/solid-2.mdc`, the `solid-2` skill directories, hook entries, managed blocks in this file and `CLAUDE.md`) are guardrails, not obstacles. Never edit, weaken, bypass, or delete them to make a failure go away — fix the flagged code. If a guardrail seems wrong, stop and tell the user.
|
|
8
|
-
- Solid 2.0 shipped recently and breaks with 1.x — never trust pre-2.0 Solid docs, tutorials, or training knowledge. Verify APIs against https://v2-rebuild--solid-docs-v2.netlify.app/llms.txt (markdown mirror of https://v2.solidjs.com/, which blocks non-browser fetchers). If the project has `@solidjs/router`, server functions, or `@solidjs/meta`, use those official pages (see the skill's official-docs index) — not React Router, Next.js, SolidStart 1.x, or Solid Router 0.x/1.x JSX `<Route>`/`<A>`. Core `action` from `solid-js` is a generator transaction; router `action`/`query` from `@solidjs/router` are POST forms and a read cache. Core `refresh(source)`, router `revalidate(key)`, and server-function `return reload(...)` are three different APIs. Pass the accessor to `isPending(user)`, not `isPending(user())`. Pass async values as values (`user={user()}`) — JSX props are lazy; types stay `User`, not `Promise<User>`. `<Loading>` wraps the read, not the memo. Nested fetches run in parallel (components do not suspend). `isPending` is a per-expression question (`isPending(() => props.story)` / `isPending(selectedId)`), not a global spinner; `latest(selectedId)` is opt-in so highlight and content stay consistent by default. `<Errored>` heals when the source succeeds. Pass a function to `render`/`hydrate`: `render(() => <App />, root)`. Component teardown is `onSettled`, not `onCleanup`. One router instance; in-app navigation is `useNavigate` / `<a href>`, not `window.location`. Types: `solid-js` exports no `JSX` namespace — children/return types are `Element` from `solid-js`; DOM `JSX` types come from `@solidjs/web`. Reset-a-signal-on-change is a writable derivation (`createSignal(() => { source(); return initial; })`), never an effect calling the setter. Client mode mounts with `render()` into an empty body — no hydration mismatch exists, so never defer initial values to post-mount React-style (that can overwrite persisted state). Client → server is additive (same setters + `action` + server functions): do not rewrite App with loading/error branches, disable optimistic rows until ack, invent tRPC, or refetch in `hydrate`/`onSettled`. The graph continues across the wire (`live()` owns the client connection; `<Reveal>` not a delayed stream). A server bundle uses `handleRequest` / Fetchable `fetch`; client-only start is static `dist/client`. One browser-only value takes `ssrSource` on the memo/signal/derived store (`client`/`hybrid`), not a `clientOnly` component split. Router link state is automatic (`aria-current`/`data-active`/`data-pending` + CSS; `useLinkState`/`useIsRouting` in JSX); search params via `useSearchParams`; router forms validate on the server (`throw respond({issues},{400})`) with inline errors from `useSubmissions`.
|
|
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
|
|
@@ -242,9 +278,16 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
242
278
|
`pending` on the record rather than a second copy of state. Do not snapshot
|
|
243
279
|
and restore, disable optimistic rows until ack, mutex rapid clicks, or freeze
|
|
244
280
|
unrelated writes because one action is in flight; failed-action replay is
|
|
245
|
-
optional.
|
|
246
|
-
{ timeout })` — the predicate reads authoritative state
|
|
247
|
-
|
|
281
|
+
optional. Confirmations that arrive on another channel wait with `yield until(predicate,
|
|
282
|
+
{ timeout })` — the predicate reads authoritative state, so the overlay cannot satisfy it.
|
|
283
|
+
That covers fire-and-forget sends **and** request/response mutations whose result is
|
|
284
|
+
read through a `live()` source: `live()` takes no part in revalidation or single-flight,
|
|
285
|
+
so after `yield save()` alone the overlay drops before the stream echoes the write and
|
|
286
|
+
the old value flashes back. Follow it with `yield until(...)`, not `refresh(liveStore)`
|
|
287
|
+
(that re-runs the derivation — the stream closes and the source reconnects — so each
|
|
288
|
+
mutation costs a reconnect, and the flash remains whenever the new connection's first
|
|
289
|
+
value predates the write). Skip `until` only for reactive clients (e.g. Convex)
|
|
290
|
+
whose subscriptions already carry the write when the mutation resolves. Compiler: `"jsxImportSource": "@solidjs/web"` (not `"solid-js"`);
|
|
248
291
|
Vite plugin is `@solidjs/vite-plugin` (not `vite-plugin-solid`) — run `solid2-kit doctor`
|
|
249
292
|
after touching `package.json` / tsconfig / root configs; it fails on React and Solid 1.x
|
|
250
293
|
wiring. Server vs browser:
|
|
@@ -302,15 +345,25 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
302
345
|
`revalidate(getUser.key)`. Do not wrap mutations in `query` (an undeclared server
|
|
303
346
|
function becomes GET). `Router.paths` is not the current location (`useLocation` /
|
|
304
347
|
`useParams`); search params via `useSearchParams` (merging setter; typed with a route
|
|
305
|
-
`search` schema + path node). A route `preload`
|
|
348
|
+
`search` schema + path node). A route `preload` *starts* reads and returns nothing —
|
|
349
|
+
`preload: ({ params }) => void getProduct(params.id)` (wrap the route in `defineRoute`
|
|
350
|
+
to type `params`) — and the page reads the same query in a memo:
|
|
351
|
+
`createMemo(() => getProduct(props.params.id))`. `props.data` is whatever `preload`
|
|
352
|
+
returned when the route first matched: it does not follow param changes (and an async
|
|
353
|
+
`preload` makes it a Promise), so return a value only for data that stays fixed while
|
|
354
|
+
the route is matched. One router instance —
|
|
306
355
|
Solid Router does not support nested `<Router>` / nested `createRouter`.
|
|
307
356
|
In-app navigation is `useNavigate` or `<a href={Router.paths...}>`, not
|
|
308
357
|
`window.location` / `history.pushState`. Link state is automatic
|
|
309
358
|
(`aria-current` / `data-active` / `data-pending` + CSS; `useLinkState` /
|
|
310
359
|
`useIsRouting` in JSX) — never hand-rolled `location.pathname` comparisons. Trusted identity is
|
|
311
|
-
`getRequestEvent()`, never a caller-supplied user id. Unscripted
|
|
312
|
-
|
|
313
|
-
|
|
360
|
+
`getRequestEvent()`, never a caller-supplied user id. Unscripted (no-JS) POST
|
|
361
|
+
forms use a router `action` over a `(form: FormData)` server function (the post
|
|
362
|
+
arrives as one `FormData`; other signatures make `action={save}` TS2322) —
|
|
363
|
+
`<form action={save} method="post">` — not the
|
|
364
|
+
function's `.url` and not a hand-built `/_server/` path: a bare `"use server"`
|
|
365
|
+
function has no typed `.url` (TS2339). `.url` is typed only on `GET()` / `live()`
|
|
366
|
+
references, for `method="get"` search forms. Client history adapters do not select the SSR URL — pass
|
|
314
367
|
`<Router url={request.url}>` (or rely on the request event). Secrets belong in
|
|
315
368
|
`virtual:env/server`, never the `client` env map / `import.meta.env`. Document head is
|
|
316
369
|
`<Title>` / `<Meta>` from `@solidjs/meta` with **no** `MetaProvider`. A static
|
|
@@ -323,8 +376,15 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
323
376
|
fallback printing `err().message` shows `"Internal Server Error"` in a production
|
|
324
377
|
server render) — so intentional client-facing failures are `markSafeError(...)` or
|
|
325
378
|
`throw respond(body, { status })`. Reads are function calls too (`await getUser(id)`
|
|
326
|
-
on a `GET()`-declared read; the method mirrors the operation).
|
|
327
|
-
|
|
379
|
+
on a `GET()`-declared read; the method mirrors the operation). `redirect()` /
|
|
380
|
+
`reload()` are signals for an integration: Solid Router's `action` / `query` and the
|
|
381
|
+
no-JS form post apply them. A direct caller — a core `action`, a handler, a plain
|
|
382
|
+
memo — never gets your data: it receives the raw `Response` as the resolved value
|
|
383
|
+
(typed as your data, no navigation); only when a mounted router's single-flight
|
|
384
|
+
hook intercepts a direct POST call does it navigate, and the call then resolves to
|
|
385
|
+
`null`. So guards `throw redirect("/sign-in")` only when router `action` /
|
|
386
|
+
`query` call the function; without the router, `throw respond(null, { status: 401 })`
|
|
387
|
+
(the call rejects) or return a value and navigate in the caller. `redirect()` /
|
|
328
388
|
`respond()` take `revalidate` like `reload()`. Cache GET reads with
|
|
329
389
|
`Cache-Control` on that response (the default is `no-store`), not a hand-rolled
|
|
330
390
|
Solid cache. HTTP does not enforce TypeScript —
|
|
@@ -346,7 +406,10 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
|
|
|
346
406
|
components from `"use server"` unless the project already enabled the
|
|
347
407
|
experimental `serverFunctions.components` flag. JSON-encodable server-function arguments
|
|
348
408
|
only, unless `enableRichArguments()` was called once in the client entry
|
|
349
|
-
(`Date` / `Map` / `Set` throw without it). `live()`
|
|
409
|
+
(`Date` / `Map` / `Set` throw without it). Declare live reads as `live(GET(fn))` —
|
|
410
|
+
`live()` outermost. A plain `async function*` server function is an event stream on
|
|
411
|
+
one connection (no reconnect when it drops); `live()` is one value that changes over
|
|
412
|
+
time and reconnects. `live()` connection state is
|
|
350
413
|
`source.onstatus` (`"connected"` / `"reconnecting"` / `"closed"`), never a
|
|
351
414
|
field in the yielded value. Sibling `<Loading>` reveal order is `<Reveal>`
|
|
352
415
|
(`collapsed` suppresses tail skeletons under sequential order).
|
|
@@ -385,7 +448,7 @@ directory (default `src/`).
|
|
|
385
448
|
| `import type { JSX } from "solid-js"` / `JSX.Element` as the children type | `solid-js` exports no `JSX` namespace: children/returns are `Element` from `"solid-js"`; DOM-specific `JSX` types (`JSX.IntrinsicElements`, `JSX.CSSProperties`) from `"@solidjs/web"` |
|
|
386
449
|
| `createResource` | async `createMemo` + `<Loading>`/`<Errored>`; `refresh()`, `latest()`, `isPending()` |
|
|
387
450
|
| `createEffect(fn)` (one arg), `on(...)` | `createEffect(compute, apply)`; deps belong in `compute` |
|
|
388
|
-
| `onMount` | `onSettled`
|
|
451
|
+
| `onMount` | `onSettled(() => { ...; return cleanup })` — return the cleanup; `onCleanup`, memo/effect creation, and `flush()` throw inside the callback |
|
|
389
452
|
| `batch(...)` | delete it — writes auto-batch; `flush()` only to observe synchronously |
|
|
390
453
|
| `<Suspense>`, `<ErrorBoundary>`, `<SuspenseList>` | `<Loading>`, `<Errored>` (fallback gets an error *accessor*), `<Reveal>` |
|
|
391
454
|
| `<Index>` | `<For keyed={false}>` |
|
|
@@ -393,7 +456,7 @@ directory (default `src/`).
|
|
|
393
456
|
| `setState("a", "b", value)` path setters, `produce(...)` | draft setter: `setState(draft => { ... })` |
|
|
394
457
|
| `mergeProps` / `splitProps` / `unwrap` | `merge` / `omit` / `snapshot` |
|
|
395
458
|
| `classList={...}` | `class` object/array form |
|
|
396
|
-
| `createMutable`, `modifyMutable` | `createStore` + draft setters |
|
|
459
|
+
| `createMutable`, `modifyMutable` | `createStore` + draft setters (direct writes to the store are silently ignored) |
|
|
397
460
|
| `createComputed`, `createSelector`, `createDeferred` | `createMemo`, `createProjection`, external scheduling |
|
|
398
461
|
| `startTransition`, `useTransition` | automatic held updates + `isPending` |
|
|
399
462
|
| `onError` / `catchError` | `<Errored>` or effect bundle `{ effect, error }` |
|
|
@@ -41,7 +41,8 @@ Practical consequences:
|
|
|
41
41
|
| Expensive derivation, multiple consumers, or equality boundary needed | `createMemo(() => ...)` |
|
|
42
42
|
| Follows a prop but user can locally override; prop change resets | writable derivation: `createSignal(() => props.value)` / `createStore(() => props.value, fallback)` |
|
|
43
43
|
| Shared by a subtree, independent per provider instance | signal/store created inside a provider component, passed via context |
|
|
44
|
-
|
|
|
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
|
|
@@ -632,6 +704,38 @@ only the echo can. Timeout rejects with `TimeoutError` (strongly recommended on
|
|
|
632
704
|
droppable transports; abort via `signal`). Outside actions, `await until(() => user())`
|
|
633
705
|
waits for a value before continuing.
|
|
634
706
|
|
|
707
|
+
The same hold applies when the mutation *does* answer but the value is read through a
|
|
708
|
+
`live()` source. A live source updates only through its open stream — it takes no part
|
|
709
|
+
in revalidation or single-flight mutation data — so the mutation's response and the
|
|
710
|
+
stream's echo race. Settle on `yield saveBid(...)` alone and the overlay is discarded
|
|
711
|
+
first: the old bid flashes back until the stream catches up.
|
|
712
|
+
|
|
713
|
+
```tsx
|
|
714
|
+
import { action, createOptimisticStore, until } from 'solid-js';
|
|
715
|
+
import { liveAuction, saveBid } from '../data/auction';
|
|
716
|
+
|
|
717
|
+
function Auction(props: { id: string }) {
|
|
718
|
+
const [auction, setAuction] = createOptimisticStore(() => liveAuction(props.id), { highBid: 0 });
|
|
719
|
+
|
|
720
|
+
const bid = action(function* (amount: number) {
|
|
721
|
+
setAuction((a) => { a.highBid = amount; }); // overlay
|
|
722
|
+
yield saveBid(props.id, amount); // the write goes up
|
|
723
|
+
yield until(() => auction.highBid >= amount, { timeout: 10_000 }); // hold for the echo
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
return <button onClick={() => bid(auction.highBid + 1)}>Bid {auction.highBid + 1}</button>;
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
Do not `refresh(auction)` in place of `until`: on a store derived from a live source it
|
|
731
|
+
re-runs the derivation — the current stream is closed and `liveAuction()` opens a new
|
|
732
|
+
connection. That costs a reconnect per mutation and hides the flash only when the new
|
|
733
|
+
connection's first value already includes the write; when the read side lags the write,
|
|
734
|
+
the old value still flashes back before the echo. Reactive clients whose
|
|
735
|
+
subscriptions already carry the write when the mutation resolves (e.g. Convex) need
|
|
736
|
+
neither. The server side of `liveAuction` / `saveBid` is in
|
|
737
|
+
[Server functions](#server-functions--use-server).
|
|
738
|
+
|
|
635
739
|
Navigation-shaped updates do **not** need core `action`. A plain setter is enough:
|
|
636
740
|
reads pull the async, and downstream async computeds hold previous values until the
|
|
637
741
|
new ones are ready (`isPending` / `latest`). Reach for `action` from `solid-js` only
|
|
@@ -740,9 +844,47 @@ function ThemeButton() {
|
|
|
740
844
|
|
|
741
845
|
Pass accessors/setters/stores through context — never `value={theme()}` (a dead snapshot).
|
|
742
846
|
No `useMemo` for the value object, no context splitting: the object is created once and
|
|
743
|
-
fine-grained updates flow through the signals inside it.
|
|
744
|
-
|
|
745
|
-
|
|
847
|
+
fine-grained updates flow through the signals inside it.
|
|
848
|
+
|
|
849
|
+
**App-wide state is the same provider, placed at the root of `App`** — not a
|
|
850
|
+
module-level signal or store. Module scope is for constants and the `createContext`
|
|
851
|
+
call itself:
|
|
852
|
+
|
|
853
|
+
```tsx
|
|
854
|
+
import { createContext, createStore, useContext, type ParentProps } from 'solid-js';
|
|
855
|
+
|
|
856
|
+
type CartItem = { id: string; quantity: number };
|
|
857
|
+
|
|
858
|
+
// WRONG under SSR — one store for every request the server ever handles
|
|
859
|
+
// export const [cart, setCart] = createStore({ items: [] as CartItem[] });
|
|
860
|
+
|
|
861
|
+
// CORRECT — one store per provider instance: per tab in the browser, per request on the server
|
|
862
|
+
function createCart() {
|
|
863
|
+
const [cart, setCart] = createStore({ items: [] as CartItem[] });
|
|
864
|
+
return { cart, setCart };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const CartContext = createContext<ReturnType<typeof createCart>>();
|
|
868
|
+
|
|
869
|
+
export function CartProvider(props: ParentProps) {
|
|
870
|
+
return <CartContext value={createCart()}>{props.children}</CartContext>;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
export function useCart() {
|
|
874
|
+
return useContext(CartContext); // throws ContextNotFoundError outside CartProvider
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// App: <CartProvider><ThemeProvider>{/* router / pages */}</ThemeProvider></CartProvider>
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
Module state has no owner (nothing disposes it), and under `ssr: true` the server
|
|
881
|
+
loads the module once and serves every request from it: `createStore(value)` on the
|
|
882
|
+
server is the plain object, so one visitor's writes render into the next visitor's
|
|
883
|
+
HTML (`[SERVER_WRITE]`). A module-level store derived from a server function fails
|
|
884
|
+
sooner — `Cannot call server function outside of a request` while the module loads.
|
|
885
|
+
In a client-only build a module-level store is one per tab and works, which is why it
|
|
886
|
+
looks fine until `ssr: true` is turned on; the provider costs nothing extra and
|
|
887
|
+
survives that change.
|
|
746
888
|
|
|
747
889
|
### Refs
|
|
748
890
|
|
|
@@ -783,6 +925,32 @@ do not `return () => cleanup`. Put owned setup in a **directive factory** (like
|
|
|
783
925
|
`ResizeObserver`, third-party widgets, and native listener options (`capture` /
|
|
784
926
|
`passive`) — Solid event props do not take those options.
|
|
785
927
|
|
|
928
|
+
### `onSettled` — one-time setup, restricted scope
|
|
929
|
+
|
|
930
|
+
`onSettled` is the `onMount` replacement, and its callback is a **restricted scope**:
|
|
931
|
+
`onCleanup` throws there (`[CLEANUP_IN_FORBIDDEN_SCOPE]`), creating a memo or effect
|
|
932
|
+
throws (`[PRIMITIVE_IN_FORBIDDEN_SCOPE]`), and so does `flush()` — each halts the
|
|
933
|
+
reactive system in dev. Return the cleanup; create effects and memos in the component
|
|
934
|
+
body, next to the `onSettled` call, not inside it.
|
|
935
|
+
|
|
936
|
+
```tsx
|
|
937
|
+
// WRONG — the mechanical 1.x port of onMount + onCleanup
|
|
938
|
+
onSettled(() => {
|
|
939
|
+
const id = setInterval(tick, 1000);
|
|
940
|
+
onCleanup(() => clearInterval(id));
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
// CORRECT
|
|
944
|
+
onSettled(() => {
|
|
945
|
+
const id = setInterval(tick, 1000);
|
|
946
|
+
return () => clearInterval(id);
|
|
947
|
+
});
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
Effect callbacks follow the same shape: return the cleanup from `apply` (an
|
|
951
|
+
`onCleanup` there never runs), and `flush()` inside one is a no-op
|
|
952
|
+
(`[FLUSH_IN_EFFECT_CALLBACK]`).
|
|
953
|
+
|
|
786
954
|
### Per-value SSR policy: `ssrSource`
|
|
787
955
|
|
|
788
956
|
`clientOnly` splits a *component*; `ssrSource` keeps one *value* off the server,
|
|
@@ -1168,8 +1336,57 @@ plain `<a href={Router.paths.about}>` (or `useNavigate`); there is no `<A>` /
|
|
|
1168
1336
|
`<Navigate>` / `<HashRouter>` / `<FileRoutes />`. Do not assign
|
|
1169
1337
|
`window.location.href` or call `history.pushState` for in-app navigation.
|
|
1170
1338
|
Session location is `useLocation` / `useParams`,
|
|
1171
|
-
not `Router.paths`.
|
|
1172
|
-
|
|
1339
|
+
not `Router.paths`.
|
|
1340
|
+
|
|
1341
|
+
**Route `preload` starts reads; the page reads them in a memo.** `preload` runs when
|
|
1342
|
+
the route matches (and on link hover), so it should kick off the query and return
|
|
1343
|
+
nothing; the component reads the same query through a memo that tracks the params:
|
|
1344
|
+
|
|
1345
|
+
```tsx
|
|
1346
|
+
// src/data/products.ts
|
|
1347
|
+
import { query } from '@solidjs/router';
|
|
1348
|
+
|
|
1349
|
+
export const getProduct = query(async (id: string) => {
|
|
1350
|
+
const response = await fetch(`/api/products/${id}`);
|
|
1351
|
+
return (await response.json()) as Product;
|
|
1352
|
+
}, 'product');
|
|
1353
|
+
|
|
1354
|
+
// src/router.ts — defineRoute types params from the path (params.id: string)
|
|
1355
|
+
import { lazy } from 'solid-js';
|
|
1356
|
+
import { createRouter, defineRoute } from '@solidjs/router';
|
|
1357
|
+
import { getProduct } from './data/products';
|
|
1358
|
+
|
|
1359
|
+
export const Router = createRouter({
|
|
1360
|
+
routes: [
|
|
1361
|
+
defineRoute({
|
|
1362
|
+
path: '/products/:id',
|
|
1363
|
+
preload: ({ params }) => void getProduct(params.id),
|
|
1364
|
+
component: lazy(() => import('./pages/Product')),
|
|
1365
|
+
}),
|
|
1366
|
+
],
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
// src/pages/Product.tsx
|
|
1370
|
+
import { createMemo } from 'solid-js';
|
|
1371
|
+
import type { RouteProps } from '@solidjs/router';
|
|
1372
|
+
import { getProduct } from '../data/products';
|
|
1373
|
+
|
|
1374
|
+
export default function ProductPage(props: RouteProps<'/products/:id'>) {
|
|
1375
|
+
const product = createMemo(() => getProduct(props.params.id));
|
|
1376
|
+
return <h1>{product().name}</h1>;
|
|
1377
|
+
}
|
|
1378
|
+
```
|
|
1379
|
+
|
|
1380
|
+
Do not return the data from `preload` and render `props.data` (the Remix-loader
|
|
1381
|
+
shape): `props.data` is whatever `preload` returned when the route first matched, so
|
|
1382
|
+
navigating `/products/mug` → `/products/bowl` keeps showing the mug, and an `async`
|
|
1383
|
+
preload makes it a Promise (`props.data.name` is TS2339). Return a value only for
|
|
1384
|
+
data that stays fixed while the route is matched. The factory `preload` on
|
|
1385
|
+
`createRouter` runs once per mount/request; its result is the root render prop's
|
|
1386
|
+
`props.data`. Plain route objects type `params` as an open record (`string |
|
|
1387
|
+
undefined` per key) — wrap a route in `defineRoute` when `preload` or the component
|
|
1388
|
+
needs `params.id: string`.
|
|
1389
|
+
|
|
1173
1390
|
Link warming: `preload="false"` skips that link's *data* preload; `preloadLinks: false`
|
|
1174
1391
|
disables automatic link preloading. Intent values are `"initial"` / `"navigate"` /
|
|
1175
1392
|
`"native"` / `"preload"`. One router per app.
|
|
@@ -1326,8 +1543,42 @@ reference runs in-process (no HTTP); in the browser it is HTTP. Anything
|
|
|
1326
1543
|
referenced only inside the `"use server"` body (db client, secrets) never
|
|
1327
1544
|
reaches the client — the directive is the privacy boundary. Mutations that should
|
|
1328
1545
|
revalidate: `redirect` / `reload` from `@solidjs/web` (see the server-function
|
|
1329
|
-
guides).
|
|
1330
|
-
|
|
1546
|
+
guides).
|
|
1547
|
+
|
|
1548
|
+
**`redirect()` / `reload()` are signals for an integration, not navigation.** Solid
|
|
1549
|
+
Router's `action` / `query` apply them (navigate, revalidate), and a no-JS form post
|
|
1550
|
+
follows the 302. Any other caller — a core `action`, an event handler, a plain
|
|
1551
|
+
`createMemo(() => listOrders())` — receives the raw `Response` as the resolved value:
|
|
1552
|
+
no navigation happens, and the type still claims your data. (When a mounted router's
|
|
1553
|
+
single-flight hook intercepts a direct POST call, it does navigate, but the call
|
|
1554
|
+
resolves to `null`; a direct `GET()` read always gets the `Response`.) Either way the
|
|
1555
|
+
caller never gets its data, so the redirect guard belongs to functions called through
|
|
1556
|
+
the router:
|
|
1557
|
+
|
|
1558
|
+
```ts
|
|
1559
|
+
import { getRequestEvent, redirect, respond } from '@solidjs/web';
|
|
1560
|
+
|
|
1561
|
+
// called through a router action/query: the router navigates to /sign-in
|
|
1562
|
+
export async function listOrders() {
|
|
1563
|
+
'use server';
|
|
1564
|
+
const userId = getRequestEvent()?.locals.userId;
|
|
1565
|
+
if (!userId) throw redirect('/sign-in');
|
|
1566
|
+
return database.orders.forUser(userId);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
// no router in the project (core action + server functions): reject instead
|
|
1570
|
+
export async function listMyOrders() {
|
|
1571
|
+
'use server';
|
|
1572
|
+
const userId = getRequestEvent()?.locals.userId;
|
|
1573
|
+
if (!userId) throw respond(null, { status: 401 });
|
|
1574
|
+
return database.orders.forUser(userId);
|
|
1575
|
+
}
|
|
1576
|
+
```
|
|
1577
|
+
|
|
1578
|
+
Without the router, fail with `throw respond(..., { status })` (the call rejects and
|
|
1579
|
+
`<Errored>` / the action's `catch` sees it), or return a value and navigate in the
|
|
1580
|
+
caller. Return a redirect when it is the successful outcome, throw it for an early
|
|
1581
|
+
exit. `redirect()` and `respond()` accept the same `revalidate`
|
|
1331
1582
|
option as `reload()` when the mutation also changes location or returns a value;
|
|
1332
1583
|
`revalidate: REVALIDATE_ALL` (`"*"`, from `@solidjs/web`) declares every cache entry
|
|
1333
1584
|
stale, distinct from omitting it (the host's default) and from `[]` (nothing). After a router/server mutation do **not** follow with a client `fetch`
|
|
@@ -1367,8 +1618,42 @@ export const fetchStory = GET(async (id: number) => {
|
|
|
1367
1618
|
});
|
|
1368
1619
|
```
|
|
1369
1620
|
|
|
1621
|
+
A value that changes over time is `live(GET(fn))` — **`live()` outermost**: `GET()`
|
|
1622
|
+
chooses the read transport and `live()` wraps the call behavior around it. `live()`
|
|
1623
|
+
does not imply `GET()`; without it the source streams over POST, which is fine when a
|
|
1624
|
+
cacheable URL is of no use.
|
|
1625
|
+
|
|
1626
|
+
```ts
|
|
1627
|
+
// src/data/auction.ts
|
|
1628
|
+
import { GET, live } from '@solidjs/web/server-functions';
|
|
1629
|
+
|
|
1630
|
+
export const liveAuction = live(
|
|
1631
|
+
GET(async function* (id: string) {
|
|
1632
|
+
'use server';
|
|
1633
|
+
yield await db.auctions.find(id); // current state first, from storage
|
|
1634
|
+
for await (const _change of db.auctions.subscribe(id)) {
|
|
1635
|
+
yield await db.auctions.find(id);
|
|
1636
|
+
}
|
|
1637
|
+
}),
|
|
1638
|
+
);
|
|
1639
|
+
|
|
1640
|
+
export async function saveBid(id: string, amount: number) {
|
|
1641
|
+
'use server';
|
|
1642
|
+
await db.bids.place(id, amount);
|
|
1643
|
+
}
|
|
1644
|
+
```
|
|
1645
|
+
|
|
1646
|
+
A plain `async function*` server function (no `live()`) is a different tool: an
|
|
1647
|
+
**event sequence** on one connection, consumed with `for await` and accumulated. When
|
|
1648
|
+
that connection drops, the iteration ends with an error and nothing reopens it; ending
|
|
1649
|
+
the iteration in the browser aborts the request and fires the server's
|
|
1650
|
+
`request.signal`. Use it for an order's status history; use `live()` when each value
|
|
1651
|
+
replaces the previous one.
|
|
1652
|
+
|
|
1370
1653
|
`live()` yields **current state** (each yield replaces the last); do not treat
|
|
1371
|
-
it as an append-only event log.
|
|
1654
|
+
it as an append-only event log. A reconnect calls the function again and its first
|
|
1655
|
+
yield replaces the stale answer, so read that first value from the state mutations write
|
|
1656
|
+
to (database, shared server state) — not from generator locals or per-connection memory. Until the first yield it is unsettled
|
|
1372
1657
|
(`<Loading>`), same as a Promise; later yields are updates — the fallback does
|
|
1373
1658
|
not return.
|
|
1374
1659
|
Wire status is a side channel on the **iterable the `live()` call returns**,
|
|
@@ -1410,10 +1695,22 @@ Express `app.use`. Server functions that return components
|
|
|
1410
1695
|
(`serverFunctions: { components: true }`) are experimental preview — do not use
|
|
1411
1696
|
them unless the project already has that flag on.
|
|
1412
1697
|
|
|
1413
|
-
Unscripted forms
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1698
|
+
Unscripted (no-JS) POST forms go through a router action —
|
|
1699
|
+
`const save = action(createTodo, "create-todo")` from `@solidjs/router`, then
|
|
1700
|
+
`<form action={save} method="post">` — not a client `preventDefault` + `fetch`, not a
|
|
1701
|
+
hand-built `/_server/` URL, and not `action={createTodo.url}`: a bare `"use server"`
|
|
1702
|
+
function keeps its declared function type, so `.url` is a type error (TS2339) even
|
|
1703
|
+
though it exists at runtime (and `serverFunctionActionUrl(createTodo)` is TS2345).
|
|
1704
|
+
The post arrives as one `FormData` argument, so the function behind `action={save}`
|
|
1705
|
+
takes `(form: FormData)` and parses and validates the fields inside; any other
|
|
1706
|
+
signature makes `action={save}` a type error (TS2322). Bind leading JSON-safe
|
|
1707
|
+
arguments with `action={save.with(id)}` for `(id: string, form: FormData)`; a function
|
|
1708
|
+
that already has a typed signature gets a separate `FormData` server function that
|
|
1709
|
+
parses and calls it.
|
|
1710
|
+
`.url` is typed only on `GET()` / `live()` references, which makes it the address of a
|
|
1711
|
+
`method="get"` search form (`action={searchProducts.url}`; the function receives the
|
|
1712
|
+
fields as `URLSearchParams`). GET forms are only for idempotent search — do not use a
|
|
1713
|
+
GET form for a mutation.
|
|
1417
1714
|
|
|
1418
1715
|
### Document head — no MetaProvider
|
|
1419
1716
|
|
|
@@ -1449,8 +1746,11 @@ how to reuse. Prefer the form on the right.
|
|
|
1449
1746
|
| `action(async function* () { await save(); setX(v); })` | `yield save()` or `await save(); yield; setX(v)` |
|
|
1450
1747
|
| `action(async (item) => { ... })` with core `action` from `solid-js` | `action(function* (item) { ...; yield ...; ... })` — only a generator re-enters the transaction (`solid2-kit check` flags this). Router `action` from `@solidjs/router` is the one that takes `async (form) => ...` |
|
|
1451
1748
|
| `todos()` on a store | `todos.length` / `todo.title` (property reads) |
|
|
1749
|
+
| `todos.push(x)` / `state.count++` / `todo.done = !todo.done` on the store itself | `setTodos((draft) => { draft.push(x); })` — writes to the store proxy outside a setter are silently ignored (no error, no warning, tsc passes) |
|
|
1750
|
+
| `createStore({ selected: new Set<string>() })` + `draft.selected.add(id)` | `selected: {} as Record<string, true>` + `draft.selected[id] = true` / `delete draft.selected[id]`, or `draft.selected = new Set([...draft.selected, id])` — `Map` / `Set` / `Date` are stored as-is, not tracked |
|
|
1452
1751
|
| `draft.todos = fresh` (wholesale fresh-tree assignment) | `reconcile(fresh, "id")`, or function-form `createStore` / `createProjection` (auto-keyed by `"id"`). Removal via `setTodos((t) => t.filter(...))` is fine — survivors keep identity |
|
|
1453
|
-
| `createEffect(() => user, (u) => log(u.name))` | `createEffect(() => user.name, (name) => log(name))` |
|
|
1752
|
+
| `createEffect(() => user, (u) => { log(u.name); })` | `createEffect(() => user.name, (name) => { log(name); })` |
|
|
1753
|
+
| `createEffect(() => title(), (t) => (document.title = t))` / `(v) => setX(v)` | `(t) => { document.title = t; }` — apply returns a cleanup or nothing; any other return value halts reactivity. An apply that only calls a local setter is rule 4 even with braces (`solid2-kit check` flags it): use a (writable) derivation |
|
|
1454
1754
|
| `<Loading fallback={<PageSkeleton />}>{/* header + data */}</Loading>` | wrap only the data slot; chrome stays outside |
|
|
1455
1755
|
| `<Loading on={id} fallback={...}>` (the accessor) | `on={id()}` — a value, so identity changes can show fallback |
|
|
1456
1756
|
| `setCount(count() + 1)` when writes can batch | `setCount((c) => c + 1)` |
|
|
@@ -1469,7 +1769,10 @@ how to reuse. Prefer the form on the right.
|
|
|
1469
1769
|
| `throw new Error("expired")` from a server function (prod) | `throw markSafeError(...)` or `throw respond(body, { status })` |
|
|
1470
1770
|
| `const r = await api.x(); if (!r.ok) ...` result-object checks at every read | throw unusable responses; contain each region with `<Errored>` |
|
|
1471
1771
|
| `GET(async (id) => { "use server"; await db.delete(id) })` | mutations stay on POST; `GET()` is for idempotent reads |
|
|
1472
|
-
| `live()` yields as an event log to append | each yield **replaces** the current answer; yield current state first |
|
|
1772
|
+
| `live()` yields as an event log to append | each yield **replaces** the current answer; yield current state first, read from the state mutations write to — a reconnect calls the function again, so generator locals or per-connection memory are not the source |
|
|
1773
|
+
| `GET(live(async function* () { ... }))` | `live(GET(async function* () { ... }))` — `live()` outermost; `live()` alone streams over POST |
|
|
1774
|
+
| a plain `async function*` `"use server"` stream for a value that must survive a dropped connection | `live()` — a plain stream is an event sequence on one connection (`for await`, accumulate); nothing reopens it when it drops |
|
|
1775
|
+
| `yield saveBid(...)` then `refresh(auction)` on a store derived from `live()` | `yield saveBid(...); yield until(() => auction.highBid >= amount, { timeout })` — `live()` is outside revalidation/single-flight; `refresh` re-runs the derivation (closes the stream, calls the source again): a reconnect per mutation, and the old value still flashes back whenever the new first value predates the write |
|
|
1473
1776
|
| `<article innerHTML={html()}>…children…</article>` | `innerHTML` **or** children, not both |
|
|
1474
1777
|
| `fallback={(error) => <p>{error.message}</p>}` | `error` is an accessor: `error().message` (or `String(error())`) |
|
|
1475
1778
|
| `import { action } from "solid-js"` on a `<form>` | `import { action } from "@solidjs/router"` + `<form action={save} method="post">` (core `action` is a generator transaction, not a form URL) |
|
|
@@ -1484,15 +1787,17 @@ how to reuse. Prefer the form on the right.
|
|
|
1484
1787
|
| `clientOnly` split (or `isServer` branch) for one browser-only *value* | `createMemo(() => localStorage.getItem(k), { ssrSource: "client" })` — `"hybrid"` when server data mixes with client signals |
|
|
1485
1788
|
| `await flush()` / `flush()` to wait on a pending memo | `await resolve(() => value())` (or Testing Library async queries); `flush()` only drains staged *sync* writes |
|
|
1486
1789
|
| `action(function* () { setX(v); })` around a navigation-shaped setter | plain setter; async computeds hold previous values. Core `action` only when writes happen *after* async work |
|
|
1487
|
-
| `
|
|
1790
|
+
| `export const [cart, setCart] = createStore(...)` for app-wide state | `createCart()` called by a `CartProvider` at the root of `App`, read with `useCart()` over `createContext<T>()` — module state has no owner and, under SSR, one instance serves every request (`[SERVER_WRITE]`). Module scope is for constants and `createContext` |
|
|
1488
1791
|
| `import { GET } from "@solidjs/start"` / `createAsync` / `useSubmission` / `<FileRoutes />` / `"use client"` | `GET` from `@solidjs/web/server-functions`; `createMemo(() => getUser(id()))`; `useSubmissions`; `fileRoutes(pageRoutes)`; Solid has no `"use client"` |
|
|
1489
1792
|
| `<Show keyed when={user()}>` by default | default `Show` (keeps children mounted); `keyed` only when internal state must reset |
|
|
1490
1793
|
| overlapping truthy `<Match>`es all expected to render | first truthy `<Match>` wins; later matches are skipped |
|
|
1491
1794
|
| missing `<HydrationScript />` / one script for many roots | once, before app markup, when the app owns the document; distinct `renderId` per extra root |
|
|
1492
|
-
| `Router.paths` as the current URL
|
|
1795
|
+
| `Router.paths` as the current URL | `useLocation` / `useParams` |
|
|
1796
|
+
| `preload: async ({ params }) => getProduct(params.id)` + `props.data.name` in the page | `preload: ({ params }) => void getProduct(params.id)` + `createMemo(() => getProduct(props.params.id))` — `props.data` is captured once when the route matches (stale on param change; a Promise for async preloads) |
|
|
1493
1797
|
| `query.get(key)` with no guaranteed entry; `query.set(key, promise)` | `query.get` throws if missing; `query.set` does not accept a promise |
|
|
1494
1798
|
| `onCleanup(() => ...)` in a component body | `onSettled(() => { ...; return cleanup })` — `onCleanup` is for custom primitives |
|
|
1495
|
-
| `onSettled` with reactive reads expecting re-runs | each call is a **single** fire; reads are untracked. Ongoing work is `createEffect
|
|
1799
|
+
| `onSettled` with reactive reads expecting re-runs | each call is a **single** fire; reads are untracked. Ongoing work is `createEffect`, created in the component body |
|
|
1800
|
+
| `onSettled(() => { const id = setInterval(tick, 1000); onCleanup(() => clearInterval(id)); })` | `onSettled(() => { const id = setInterval(tick, 1000); return () => clearInterval(id); })` — `onCleanup`, `createMemo` / `createEffect`, and `flush()` throw inside the callback |
|
|
1496
1801
|
| `<Router>` nested inside `<Router>` | one instance; nest `children` arrays (or a lazy children thunk) |
|
|
1497
1802
|
| `const View = tab() ? A : B; return <View />` | `const View = dynamic(() => (tab() ? A : B))` — the ternary at setup freezes the choice |
|
|
1498
1803
|
| `<For each={resolved()}>` after `children()` | `{resolved.toArray()}` — resolved children are not a reactive data list |
|
|
@@ -1503,7 +1808,7 @@ how to reuse. Prefer the form on the right.
|
|
|
1503
1808
|
| `createMemo(async fn, { loadingValue })` / `{ seedLoadingValue: true }` as the default first-flight UI | `<Loading>` for first flight; those options are escape hatches (store projections use `seedLoadingValue`) |
|
|
1504
1809
|
| `setSubmitted(true)` + an effect that watches it | do the work in the handler or an `action` |
|
|
1505
1810
|
| `findUser(userId)` with the id from the client as identity | `getRequestEvent()!.locals.userId` |
|
|
1506
|
-
| `action={"/_server/" + id}`
|
|
1811
|
+
| `action={"/_server/" + id}` / `action={createTodo.url}` on a POST form | a router action: `const save = action(createTodo, "create-todo")` + `<form action={save} method="post">` over a `(form: FormData)` server function (other signatures are TS2322) — `.url` on a bare `"use server"` function is TS2339; it is typed only on `GET()` / `live()` references (GET search forms) |
|
|
1507
1812
|
| `<form method="get" action={update.url}>` for a mutation | GET forms only for idempotent search; mutations are POST |
|
|
1508
1813
|
| `window.location.href = ...` / `history.pushState` | `useNavigate()` or `<a href={Router.paths...}>` |
|
|
1509
1814
|
| `class={{ active: location.pathname === "/about" }}` hand-rolled link state | CSS on automatic `aria-current` / `data-active` / `data-pending`; `useLinkState` in JSX, `useIsRouting()` for progress bars |
|
|
@@ -1518,7 +1823,7 @@ how to reuse. Prefer the form on the right.
|
|
|
1518
1823
|
| `class={{ pending: isPending(id) }}` flashing on every navigation | same class + a short CSS `transition-delay` so only slow swaps show |
|
|
1519
1824
|
| `<Comments />` nested under `{story().title}` assumed to waterfall | both mount immediately and fetch in parallel if the child reads an already-known id; a real waterfall is `createMemo(() => fetchAuthor(story().authorId))` or `storyId={props.story.id}` |
|
|
1520
1825
|
| `<Errored>` as a terminal ErrorBoundary; `reset={() => location.reload()}` | the boundary heals when the source succeeds again; `reset` retries collected *sources*, not a UI remount |
|
|
1521
|
-
| `<Errored>` around a list
|
|
1826
|
+
| `<Errored>` around a list expected to catch a toggle/save failure | an error thrown out of an `action` never reaches `<Errored>` — it reverts the overlay and rejects the call's promise. Catch expected failures in the `action` (row errors in a map the projection folds in, which survives overlay discard; otherwise a toast) and `.catch` the call in the handler |
|
|
1522
1827
|
| `yield { ...tick, connected: true }` from `live()` | yield the value; wire status is `source.onstatus` (`"connected"` / `"reconnecting"` / `"closed"`) |
|
|
1523
1828
|
| sibling `<Loading>` fallbacks stacking as popcorn | `<Reveal collapsed>` (sequential default) suppresses tail skeletons past the frontier |
|
|
1524
1829
|
| in-memory cache around a GET server function | `return respond(value, { headers: { "cache-control": "..." } })` — metadata rides HTTP, the value rides the graph |
|
|
@@ -1530,6 +1835,7 @@ how to reuse. Prefer the form on the right.
|
|
|
1530
1835
|
| a held write with no feedback anywhere | pair it with `isPending()` / `latest()` / an optimistic value / `affects()` — otherwise dev + attribution reports `[SILENT_HOLD]` |
|
|
1531
1836
|
| `refresh(getUser.key)` / `revalidate(user)` / `return refresh()` from `"use server"` | three APIs: core `refresh(source)` reruns a reactive source; router `revalidate(getUser.key)` invalidates the query cache; `return reload({ revalidate: "todos" })` asks the integration to refresh cached data |
|
|
1532
1837
|
| `return redirect(...)` as the only redirect shape | guards `throw redirect("/sign-in")`; `redirect()` / `respond()` take `revalidate` too when the mutation also moves or returns a value |
|
|
1838
|
+
| `throw redirect("/sign-in")` / `return reload(...)` in a function called directly (core `action`, handler, plain memo) | only Solid Router `action` / `query` (and no-JS form posts) apply them — a direct caller resolves to the raw `Response` (or `null` when the router's single-flight hook intercepts a POST call), typed as your data. Without the router: `throw respond(null, { status: 401 })` or return a value and navigate in the caller |
|
|
1533
1839
|
| `submission.clear()` from an effect | on dismiss/resubmit only — from an effect, errors flash and vanish |
|
|
1534
1840
|
| `revalidate(liveSource)` | `live()` updates through the open stream; do not revalidate it |
|
|
1535
1841
|
| `refresh(user)` without `affects` when the reload should look pending | pair `affects(user)` with `refresh(user)` — a bare `refresh` re-asks quietly |
|
|
@@ -1564,7 +1870,8 @@ how to reuse. Prefer the form on the right.
|
|
|
1564
1870
|
| `createRenderEffect` / `createTrackedEffect` as the default effect | two-phase `createEffect(compute, apply)` |
|
|
1565
1871
|
| `createEffect(() => source(), () => setError(null))` to reset a signal | `createSignal(() => { source(); return null; })` — the writable derivation resets on change; a sole-setter apply is rule 4 |
|
|
1566
1872
|
| a "server-safe" initial value adopted after mount (React hydration-mismatch fix) | client mode mounts with `render()` into an empty body — no hydration, no mismatch; read `localStorage` / `matchMedia` directly at signal creation |
|
|
1567
|
-
| rewrite `App.tsx` with loading/error branches, or snapshot/restore, when the list moves to the server | same `setTodos`; wrap mutations in `action`; swap `createOptimisticStore` + a server-functions file. The overlay is discarded; the confirmed store is the truth |
|
|
1873
|
+
| rewrite `App.tsx` with loading/error branches, or snapshot/restore, when the list moves to the server | same `setTodos`; wrap mutations in `action`; swap in function-form `createOptimisticStore(() => api.list(), [])` + a server-functions file. The overlay is discarded; the confirmed store is the truth |
|
|
1874
|
+
| `createOptimisticStore<Todo[]>([])` holding the list, saved rows written after `yield` | `createOptimisticStore(() => api.list(), [])` + `refresh(todos)` (or a function form over a separate `createStore`) — the value form is a pure overlay: action writes vanish on settle |
|
|
1568
1875
|
| disable an optimistic row until ack, or mutex rapid clicks / freeze unrelated writes | keep the control enabled; `action` is the transaction. An in-flight action does not freeze other writes. Failed-action replay is optional |
|
|
1569
1876
|
| tRPC / RPC type-gen around `"use server"` | TypeScript does not cross HTTP — validate inside the function. Locals only the body reads (db, secrets) stay off the client |
|
|
1570
1877
|
| `src/routes/api/todos.ts` (or a Next `route.ts`) wrapping `db.todos.insert` | `"use server"` *is* the RPC. API routes are for real HTTP endpoints (webhooks), not the app's own mutations |
|
|
@@ -1717,16 +2024,19 @@ always-applied rules installed alongside this skill.
|
|
|
1717
2024
|
`loading`/`error` signals or `=== undefined` readiness branches. First load is
|
|
1718
2025
|
`<Loading>`; refetch indicator is `isPending(user)` (the accessor, not `user()`)
|
|
1719
2026
|
or `isPending(selectedId)` when the write is the question. `<Errored>` heals
|
|
1720
|
-
when the source succeeds; `reset` retries sources, not a UI remount.
|
|
1721
|
-
|
|
1722
|
-
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` /
|
|
1723
2031
|
`configureServerErrors` (or `render(..., { onError })`), never a side effect in
|
|
1724
2032
|
the fallback. Core `refresh(source)`, router `revalidate(key)`, and `return reload(...)` are
|
|
1725
2033
|
different APIs — do not mix them. Client → server is additive (same
|
|
1726
2034
|
setters + `action` + server functions): do not rewrite App with
|
|
1727
2035
|
loading/error branches, disable optimistic rows until ack, or refetch in
|
|
1728
2036
|
`hydrate` / `onSettled`. HTTP does not enforce TypeScript — validate
|
|
1729
|
-
inside `"use server"`; do not invent tRPC. `live()` owns the client connection
|
|
2037
|
+
inside `"use server"`; do not invent tRPC. `live()` owns the client connection,
|
|
2038
|
+
is declared `live(GET(fn))`, and a mutation on a live-read value holds with
|
|
2039
|
+
`yield until(...)` for the echo instead of `refresh()`.
|
|
1730
2040
|
A server bundle uses `handleRequest` from `dist/server/server.js`; client-only start
|
|
1731
2041
|
is static `dist/client`. Do not delay `renderToStream` for visual order
|
|
1732
2042
|
(`<Reveal>` does).
|
|
@@ -1742,7 +2052,10 @@ always-applied rules installed alongside this skill.
|
|
|
1742
2052
|
component choice with `dynamic()`. No `React.lazy`, no effects inside ref callbacks.
|
|
1743
2053
|
- [ ] Browser-only code uses `isServer` / `clientOnly` (components) / `ssrSource`
|
|
1744
2054
|
(values), not `typeof window`.
|
|
1745
|
-
Component teardown is `onSettled` (return cleanup), not `onCleanup
|
|
2055
|
+
Component teardown is `onSettled` (return cleanup), not `onCleanup`; nothing
|
|
2056
|
+
inside the `onSettled` callback calls `onCleanup`, `flush()`, or creates a
|
|
2057
|
+
memo/effect. Effect `apply` functions have block bodies and return only a
|
|
2058
|
+
cleanup or nothing.
|
|
1746
2059
|
- [ ] Dev console shows no diagnostics: reads in tracking scopes (no
|
|
1747
2060
|
`[STRICT_READ_UNTRACKED]`), store setters synchronous and never called at
|
|
1748
2061
|
body level (no `[ASYNC_STORE_SETTER]` / `[REACTIVE_WRITE_IN_OWNED_SCOPE]`), held
|
|
@@ -66,6 +66,7 @@ read these before writing route JSX.
|
|
|
66
66
|
## Guides and migration
|
|
67
67
|
|
|
68
68
|
- https://v2-rebuild--solid-docs-v2.netlify.app/guides/avoid-unnecessary-effects.md
|
|
69
|
+
- https://v2-rebuild--solid-docs-v2.netlify.app/guides/state-management.md
|
|
69
70
|
- https://v2-rebuild--solid-docs-v2.netlify.app/guides/debugging-reactivity.md
|
|
70
71
|
- https://v2-rebuild--solid-docs-v2.netlify.app/guides/performance.md
|
|
71
72
|
- https://v2-rebuild--solid-docs-v2.netlify.app/guides/observability.md
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solid2-agent-kit",
|
|
3
|
-
"version": "0.
|
|
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",
|