solid2-agent-kit 0.20.0 → 0.21.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.
@@ -534,12 +534,14 @@ directory (default `src/`).
534
534
 
535
535
  When unsure about any API, verify against the official Solid 2.0 docs — fetchable URLs are
536
536
  listed in `references/official-docs.md` next to the `solid-2` skill. Do not guess from
537
- Solid 1.x or React memory. Two official pages still describe the previous release —
537
+ Solid 1.x or React memory. Some official pages still describe the previous release —
538
538
  these rules win there: the router navigation page lists `data-pending` as automatic
539
- (it needs `links: pendingLinks`) <!-- upstream:docs-pending-links-stale -->, and the
539
+ (it needs `links: pendingLinks`) <!-- upstream:docs-pending-links-stale -->, the
540
540
  `createSignal` reference says a write to a writable derivation wins over a same-tick
541
541
  recompute (a source change in the same update now re-runs it with the write as
542
- `prev`). <!-- upstream:docs-derived-write-stale -->
542
+ `prev`) <!-- upstream:docs-derived-write-stale -->, and the performance guide and
543
+ attribution reference give `[WIDE_SCOPE_DEPS]` a threshold of 30 (the default
544
+ `wideDeps` is 200). <!-- upstream:docs-wide-deps-stale -->
543
545
 
544
546
  `createRenderEffect` exists in Solid 2 but is not a default. `storePath`
545
547
  (the 1.x path-setter migration helper) is no longer exported from `solid-js` as of
@@ -548,29 +548,65 @@ and thrown errors reach `<Errored>`. Integration layers should return a plain ac
548
548
  ```tsx
549
549
  import { createMemo, onCleanup } from 'solid-js';
550
550
 
551
- function createSubscriptionQuery<T>(
552
- subscribe: (next: (value: T) => void, fail: (error: unknown) => void) => () => void,
551
+ function createSubscriptionQuery<A, T>(
552
+ args: () => A,
553
+ subscribe: (args: A, next: (value: T) => void, fail: (error: unknown) => void) => () => void,
554
+ name: string,
553
555
  ) {
554
- const queue: T[] = [];
555
- let failure: unknown;
556
- let wake = () => {};
557
- // Custom primitive: unsubscribe is tied to the caller's owner via onCleanup.
558
- // Component bodies use onSettled and return cleanup instead.
559
- onCleanup(subscribe(
560
- (value) => { queue.push(value); wake(); },
561
- (error) => { failure = error; wake(); },
562
- ));
563
-
564
- return createMemo(() => (async function* () {
565
- while (true) {
566
- if (failure !== undefined) throw failure;
567
- if (queue.length > 0) { yield queue.shift() as T; continue; }
568
- await new Promise<void>((resolve) => { wake = resolve; });
569
- }
570
- })());
556
+ return createMemo(() => {
557
+ const resolved = args(); // tracked, and read before any await: a change re-subscribes
558
+ let current: T | undefined;
559
+ let version = 0;
560
+ let failure: unknown;
561
+ let disposed = false;
562
+ let wake = () => {};
563
+ const unsubscribe = subscribe(
564
+ resolved,
565
+ (value) => { current = value; version += 1; failure = undefined; wake(); },
566
+ (error) => { failure = error; wake(); },
567
+ );
568
+ // Inside a computation, onCleanup runs before the next run and on disposal.
569
+ onCleanup(() => { disposed = true; unsubscribe(); wake(); });
570
+
571
+ return (async function* () {
572
+ let seen = 0;
573
+ while (!disposed) {
574
+ if (failure !== undefined) throw failure;
575
+ if (version > seen) { seen = version; yield current as T; continue; }
576
+ await new Promise<void>((resolve) => { wake = resolve; });
577
+ }
578
+ })();
579
+ }, { name }); // e.g. `query:${functionName}` — attribution tables name rows by it
571
580
  }
572
581
  ```
573
582
 
583
+ Open the subscription **inside the compute**, not around the memo. Then a change of
584
+ `args` closes the old subscription and opens the new one, `refresh(query)` and
585
+ `<Errored>`'s `reset` re-run the compute into a fresh subscription with the failure
586
+ cleared (a subscription opened outside the memo keeps its failure, so a retry never
587
+ recovers), and unmounting the owner closes it. Results that are snapshots (query
588
+ results) conflate to the latest value, as above; an event stream where every message
589
+ matters keeps a queue instead. A memo's compute runs **when it is created**, not on
590
+ first read (only `{ lazy: true }` defers it, and that also tears the memo down whenever
591
+ nothing observes it), so a subscription memo created in a provider connects even if
592
+ nothing reads it — resolve the client inside the compute and return a never-settling
593
+ iterable when there is none, rather than constructing it at module scope.
594
+
595
+ Identity-like subscriptions (the session, the current user) are app-wide state
596
+ (rule 11): create them **once**, in a provider at the root. One per page is torn down
597
+ on unmount and re-opened on mount, so every navigation starts with a fallback for one
598
+ round trip, and a page query that waits on it behind its own boundary is a sequential
599
+ pair — `[ASYNC_WATERFALL]` under attribution.
600
+
601
+ Clients that share one subscription per query-and-args and hand its current value to
602
+ every new subscriber (Convex, most reactive database clients) prefetch by subscribing
603
+ **early**: in the route's `preload` on link intent, held open for a short grace period
604
+ (seconds) and released at once on error, so a hover while signed out leaves no failure
605
+ for the page to inherit. Skip `intent === "initial"` there — on the first match the page
606
+ subscribes on mount anyway, and an identity-gated read would run before the session is
607
+ restored. Nothing is cached outside the client; the page's own subscription is the only
608
+ one left when the hold expires.
609
+
574
610
  Server-function `live()` sources are the same shape (async iterable → memo). Connection
575
611
  state is `source.onstatus` (`"connected"` / `"reconnecting"` / `"closed"`), not a field
576
612
  in the yielded value — see [Server functions](#server-functions--use-server).
@@ -799,6 +835,30 @@ Never `{todos().map((t) => <Row todo={t} />)}` and never `<For each={todos().map
799
835
  The `.map` form *renders*; it just rebuilds every row. `children().toArray().map` is
800
836
  fine — those nodes are already resolved.
801
837
 
838
+ Join and narrow **upstream of the list**, not per row. A row that narrows its own
839
+ lookup — `<For each={layout().cards}>{(card) => <Show when={byKey().get(card().key)}>…}` —
840
+ makes every row a reactive source of the list's insert effect (one per row; a plain
841
+ element or component row costs none), so every row change re-runs one effect that
842
+ tracks the whole list. Nothing flags it below 2000 rows: `[WIDE_SCOPE_DEPS]` skips that
843
+ insert pass by design (and defaults to 200 sources), and only the always-on
844
+ `[HUGE_FAN_IN]` reports it, from 2000 — so this is a review rule, not something a
845
+ diagnostics gate catches. <!-- upstream:per-row-show-insert-sources --> Build the joined
846
+ rows in one memo and iterate those; `flatMap` drops the misses without a `!`:
847
+
848
+ ```tsx
849
+ const cards = createMemo(() => {
850
+ const byKey = new Map(props.matches.map((m) => [m.key, m]));
851
+ return layout().cards.flatMap((card) => {
852
+ const match = byKey.get(card.key);
853
+ return match === undefined ? [] : [{ card, match }];
854
+ });
855
+ }, { name: "cards" });
856
+
857
+ <For each={cards()} keyed={(row) => row.card.key}>
858
+ {(row) => <MatchCard match={row().match} x={row().card.x} />}
859
+ </For>
860
+ ```
861
+
802
862
  `<Repeat from={start()} count={20}>{(index) => ...}</Repeat>` renders by absolute index with
803
863
  no array diffing — use for fixed slot counts and virtual scrolling windows.
804
864
 
@@ -1078,9 +1138,11 @@ Testing Library's `render(…, { location })` is typed but not implemented (1.0.
1078
1138
  `jsxImportSource` for tests and app code is `"@solidjs/web"`, not `"solid-js"`.
1079
1139
  Vite plugin is `@solidjs/vite-plugin`, not `vite-plugin-solid`.
1080
1140
 
1081
- To turn those dev diagnostics into a hard gate, patch `console.warn` in the test setup
1082
- file to rethrow unexpected warnings — then any top-level reactive read or unowned write
1083
- an agent sneaks in fails the suite instead of scrolling by.
1141
+ To turn those dev diagnostics into a hard gate on every test, capture them per test
1142
+ with `@solidjs/diagnostics` — see
1143
+ [Gating every test on diagnostics](#gating-every-test-on-diagnostics). Patching
1144
+ `console.warn` sees only what the console reporter prints, and none of the
1145
+ attribution-tier findings or the hold tables.
1084
1146
 
1085
1147
  ### Dev diagnostics and attribution
1086
1148
 
@@ -1208,7 +1270,9 @@ expect(artifact).toHaveNoDiagnostics();
1208
1270
  expect(artifact).toHaveNoSilentHolds();
1209
1271
  ```
1210
1272
 
1211
- `toHaveNoDiagnostics` fails on any coded warning (`info` findings excluded);
1273
+ `toHaveNoDiagnostics` fails on every captured finding, `info` ones included
1274
+ (`[FALLBACK_FLASH]`, `[OPTIMISTIC_REVERTED]`, …) — its `allow` list names codes to
1275
+ tolerate;
1212
1276
  `toStayWithinRerunBudget` / `toHaveNoWaste` catch recompute regressions. The
1213
1277
  `agent-loops` skill the package ships
1214
1278
  (`node_modules/@solidjs/diagnostics/skills/agent-loops/SKILL.md`) documents the agent
@@ -1216,6 +1280,101 @@ verification loops: capture until the channel is quiet, scenario budgets as the
1216
1280
  definition of done, and hold/latency gates. See the debugging-reactivity guide in
1217
1281
  [references/official-docs.md](references/official-docs.md).
1218
1282
 
1283
+ ### Gating every test on diagnostics
1284
+
1285
+ Solid grades its findings `info` / `warn` / `error` and offers no switch to escalate
1286
+ them, so "diagnostics are errors" is enforced in tests. The shape that holds up is a
1287
+ setup file that wraps **every** test body in one capture, so a test written by someone
1288
+ who never heard of the engine is gated anyway:
1289
+
1290
+ ```ts
1291
+ // src/test-setup.ts — listed in the vitest project's `setupFiles`
1292
+ import { afterEach, beforeEach } from 'vitest';
1293
+ import { flush } from 'solid-js';
1294
+ import { captureArtifact, expectNoDiagnostics, expectNoSilentHolds } from '@solidjs/diagnostics';
1295
+
1296
+ let end: (() => void) | undefined;
1297
+ let capture: ReturnType<typeof captureArtifact<void>> | undefined;
1298
+ export const roots: (() => void)[] = []; // a `mount()` helper pushes each render's dispose
1299
+
1300
+ beforeEach((context) => {
1301
+ if (capture) throw new Error('tests under the gate must run sequentially');
1302
+ capture = captureArtifact<void>(() => new Promise<void>((resolve) => { end = resolve; }), {
1303
+ scenario: context.task.name,
1304
+ attribution: { hotTime: false, fallbackFlashes: false }, // wall-clock: see below
1305
+ });
1306
+ });
1307
+
1308
+ afterEach(async () => {
1309
+ for (const dispose of roots.splice(0).reverse()) dispose();
1310
+ flush();
1311
+ end?.();
1312
+ const pending = capture;
1313
+ capture = undefined;
1314
+ if (pending === undefined) throw new Error('this test ran outside the gate');
1315
+ const { artifact } = await pending; // close the capture before asserting anything
1316
+ expectNoDiagnostics(artifact);
1317
+ expectNoSilentHolds(artifact);
1318
+ });
1319
+ ```
1320
+
1321
+ What the shape has to get right:
1322
+
1323
+ - **A DOM environment** (`happy-dom` / `jsdom`) for every reactive test, `.test.ts`
1324
+ included. Under vitest's `node` environment the export conditions select Solid's
1325
+ **server build**: writes are inert (`[SERVER_WRITE]`), effects never run, and the
1326
+ attribution tables stay empty — a green test that proves nothing. Components that only
1327
+ exist in the server build (a `<html>` document shell) get their own `node` project
1328
+ rendered with `renderToString`, outside the gate.
1329
+ - **No HMR transform in the test project**: `solid({ refresh: { disabled: true } })`.
1330
+ The refresh transform wraps every component declaration in a memo, so the graph
1331
+ under test is not the graph production ships, and tests have no HMR to serve. (The
1332
+ wrappers are marked as plumbing, so the engine does not count them as sources.)
1333
+ <!-- upstream:per-row-show-insert-sources -->
1334
+ - **Two assertions, not one.** `[SILENT_HOLD]` is emitted only once a hold outlasts
1335
+ `holds.infoMs` (100ms), so a shorter unacknowledged hold is recorded and coded
1336
+ nowhere; `expectNoSilentHolds` asks the hold table, where no threshold applies.
1337
+ - **Wall-clock checks off where the clock means nothing.** `hotTime`'s 8ms budget is
1338
+ half a real browser frame; an emulated DOM spends more than that rendering a page, and
1339
+ a loaded runner several times more, so the failure names an innocent component.
1340
+ `[FALLBACK_FLASH]` measures backend latency, and a fake that settles at once flashes
1341
+ every fallback. Keep the count-based and structural checks (holds, waterfalls, list
1342
+ identity, wide scopes); measure time in a real browser.
1343
+ - **Sequential tests only.** The diagnostics channel and the engine are process-global,
1344
+ so under `test.concurrent` one test's finding lands in another's artifact — refuse the
1345
+ overlap rather than blame the wrong test.
1346
+ - **Dispose what the test mounted** before the capture closes. A root left alive re-runs
1347
+ on later writes, and its finding fails whichever test is open then.
1348
+ - **Settle deferred work** (`await resolve(...)`, `flush()`) before the body returns: an
1349
+ un-awaited timer or promise lands in the next test's capture, or in none. Module scope
1350
+ runs before the first capture opens — keep fixtures there, never a render.
1351
+ - **The app's own `attribution.enable()` stands down under a harness**
1352
+ (`if (import.meta.env.VITEST) return;`, and in the browser gate's mode). A hold taken
1353
+ while the engine is enabled opens a fresh window over `history()` / `costs()` /
1354
+ `feedback()`, which erases what the capture recorded so far, and options combine by
1355
+ the most demanding value per key, so the app's default hold re-arms every threshold
1356
+ the gate turned off. <!-- upstream:attribution-hold-window -->
1357
+ - **A test whose subject is a finding requires it** (`expectDiagnostic(artifact, code)`
1358
+ and then `allow` it), so the test fails when it stops provoking the code instead of
1359
+ decaying into a mute. Never `allow` a finding about real code.
1360
+ - **Fixtures above the threshold** for scale-dependent codes. `[WIDE_SCOPE_DEPS]` fires
1361
+ at 200 dependencies by default, `[HUGE_FAN_OUT]` / `[HUGE_FAN_IN]` and
1362
+ `[HOT_SCOPE_RERUNS]` at their own counts, so a fixture of three rows cannot falsify a
1363
+ per-row cost. When a gate is meant to catch one, give it a fixture past the code's
1364
+ threshold (or a lower threshold in the capture's `attribution` options) and check it
1365
+ fails with the defect re-injected. A per-row cost in a list's own insert pass is
1366
+ outside `[WIDE_SCOPE_DEPS]` entirely (see [Lists](#lists-for-child-signatures-per-keying-mode)).
1367
+
1368
+ An emulated DOM cannot see everything, so keep one **real-browser gate** for the main
1369
+ path: `captureBrowserArtifact(page, interact, { scenario })` from
1370
+ `@solidjs/diagnostics/playwright` drives the same capture through the bridge the Vite
1371
+ plugin injects into `vite dev`, and returns the same artifact, so one verdict function
1372
+ judges both. Serve that dev server in a dedicated mode with `refresh: { disabled: true }`
1373
+ and `performanceTracks: false`: the tracks paint every record from inside the scopes the
1374
+ engine times, enough to push a page's first render past the 8ms `[HOT_SCOPE_TIME]`
1375
+ budget. The wall-clock thresholds mean something there — except `fallbackFlashes`
1376
+ against a backend on loopback.
1377
+
1219
1378
  ### Development loop: self-diagnose before finishing
1220
1379
 
1221
1380
  Enable observability in development **by default** and lean on it. The dev build
@@ -1492,7 +1651,12 @@ Link state needs no component code: the router sets `aria-current="page"` (same
1492
1651
  and same query — parameter order and hash aside), `data-active` (exact or descendant
1493
1652
  on the path only — the `/` link is active only on exact match), and, with the opt-in
1494
1653
  `createRouter({ links: pendingLinks })` plugin, `data-pending` (in-flight navigation
1495
- target) on the anchors it handles; style them in CSS. Highlight nav links on
1654
+ target) on the anchors it handles; style them in CSS. The plugin is also a *reader* of
1655
+ the in-flight navigation: without it, or a `useLinkState().pending` / `isPending` read of
1656
+ the location somewhere on screen, nothing reads it, so every navigation that waits on a
1657
+ lazy page or its first data is an unacknowledged hold (`[SILENT_HOLD]` past 100ms,
1658
+ shorter ones only in `toHaveNoSilentHolds`). Create the router with
1659
+ `links: pendingLinks` by default, even when no stylesheet uses `data-pending` yet. <!-- upstream:pending-links-opt-in --> Highlight nav links on
1496
1660
  `data-active`, not `aria-current`: a nav link to `/products` is not current on
1497
1661
  `/products?page=2`. An `aria-current` you write
1498
1662
  yourself is yours — the router only manages the attribute on links where it set it.
@@ -1967,6 +2131,8 @@ how to reuse. Prefer the form on the right.
1967
2131
  | `Object.assign({}, props)` / expecting `merge` to skip `undefined` like 1.x `mergeProps` | `omit` / `merge` — explicit `undefined` **overrides** (Object.assign) |
1968
2132
  | `isPending(user())` / `latest(user())` | `isPending(user)` / `latest(user)` — pass the accessor (or `() => user()`). Calling it first evaluates the read before the helper runs |
1969
2133
  | `isPending(...)` as the first-load spinner | `<Loading>` owns first flight; `isPending` is the *refetch* indicator after a settled answer exists |
2134
+ | `<For each={rows()}>{(row) => <Show when={byKey().get(row().key)}>…</Show>}</For>` (narrowing per row) | join in one memo upstream and iterate the joined rows (`flatMap` drops misses) — a per-row `<Show>` makes every row a source of the list's insert effect, which no diagnostic flags below `[HUGE_FAN_IN]`'s 2000 <!-- upstream:per-row-show-insert-sources --> |
2135
+ | a subscription opened beside `createMemo(() => iterate())` instead of inside its compute | open it inside the compute with `onCleanup` there — args changes re-subscribe, `refresh` / `reset` retry with a fresh subscription, unmount closes it |
1970
2136
  | a held write with no feedback anywhere | pair it with `isPending()` / `latest()` / an optimistic value / `affects()` — otherwise dev + attribution reports `[SILENT_HOLD]` |
1971
2137
  | `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 |
1972
2138
  | `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 |
@@ -2136,6 +2302,10 @@ Known stale pages — this skill is verified against the installed release and w
2136
2302
  - `reference/solid-js/reactivity/create-signal` says a write to a writable derivation
2137
2303
  wins over a same-tick recompute; a source change in the same update re-runs the
2138
2304
  function, which receives the write as `prev`. <!-- upstream:docs-derived-write-stale -->
2305
+ - `guides/performance` ("tracks 30 or more sources") and the attribution reference
2306
+ (`wideDeps`, "default 30") give the old `[WIDE_SCOPE_DEPS]` threshold; the default is
2307
+ 200, and a list's insert pass is exempt from the check (see
2308
+ [Lists](#lists-for-child-signatures-per-keying-mode)). <!-- upstream:docs-wide-deps-stale -->
2139
2309
 
2140
2310
  **Never** consult Solid 1.x sources (docs.solidjs.com, pre-2.0 tutorials, old Stack Overflow).
2141
2311
  Solid 2.0 is a breaking rewrite; 1.x answers are wrong here. The banned-API table is in the
@@ -2215,6 +2385,10 @@ always-applied rules installed alongside this skill.
2215
2385
  `feedback`) whenever the change touched stores, lists, async, actions, or
2216
2386
  effects. Name interrogated scopes (`{ name: "total" }`). `observe: true` is a
2217
2387
  production opt-in, not a dev step.
2388
+ - [ ] Tests that render run under a DOM environment with the HMR transform off and a
2389
+ per-test capture asserting `expectNoDiagnostics` + `expectNoSilentHolds`; app code
2390
+ does not call `attribution.enable()` under the harness. List rows do not narrow
2391
+ themselves with a per-row `<Show>` (no diagnostic catches it).
2218
2392
  - [ ] If the project has a router: `createRouter({ routes })`, not JSX `<Route>` / `<A>`.
2219
2393
  One instance, no nested `<Router>`. Navigate with `useNavigate` / `<a href>`, not
2220
2394
  `window.location`. Router `action`/`query` come from `@solidjs/router` (POST forms + cache), not
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "solid2-agent-kit",
3
- "version": "0.20.0",
3
+ "version": "0.21.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",