solid2-agent-kit 0.20.0 → 0.22.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/bin/baseline.mjs CHANGED
@@ -18,14 +18,14 @@
18
18
  // pulls them in — so doctor also checks an undeclared install of any package
19
19
  // here, which is where a lockfile holds an older compiler.
20
20
  export const BASELINE = {
21
- 'solid-js': { line: 2, baseline: '2.0.0-rc.14' },
22
- '@solidjs/web': { line: 2, baseline: '2.0.0-rc.14' },
23
- '@solidjs/signals': { line: 2, baseline: '2.0.0-rc.14' },
24
- '@solidjs/diagnostics': { line: 2, baseline: '2.0.0-rc.14' },
25
- '@solidjs/compiler': { line: 2, baseline: '2.0.0-rc.14' },
26
- '@solidjs/babel-plugin': { line: 2, baseline: '2.0.0-rc.14' },
27
- '@solidjs/router': { line: 2, baseline: '2.0.0-next.37' },
28
- '@solidjs/vite-plugin': { line: 3, baseline: '3.0.0-next.47' },
21
+ 'solid-js': { line: 2, baseline: '2.0.0-rc.15' },
22
+ '@solidjs/web': { line: 2, baseline: '2.0.0-rc.15' },
23
+ '@solidjs/signals': { line: 2, baseline: '2.0.0-rc.15' },
24
+ '@solidjs/diagnostics': { line: 2, baseline: '2.0.0-rc.15' },
25
+ '@solidjs/compiler': { line: 2, baseline: '2.0.0-rc.15' },
26
+ '@solidjs/babel-plugin': { line: 2, baseline: '2.0.0-rc.15' },
27
+ '@solidjs/router': { line: 2, baseline: '2.0.0-next.39' },
28
+ '@solidjs/vite-plugin': { line: 3, baseline: '3.0.0-next.50' },
29
29
  '@solidjs/testing-library': { line: 1, baseline: '1.0.0-beta.3' },
30
30
  };
31
31
 
@@ -767,6 +767,15 @@ const CHECKS = [
767
767
  message:
768
768
  'Lowercase `on*` is a plain attribute in Solid 2 (dev warns [LOWERCASE_EVENT_ATTRIBUTE]), not an event binding. Use the camelCase prop (onClick).',
769
769
  },
770
+ {
771
+ // `preloadLinks` takes link preload strategies since @solidjs/router
772
+ // 2.0.0-next.38 and preloads nothing by default: `true` (the old on switch)
773
+ // preloads nothing and only warns in dev, and `false` is a no-op.
774
+ id: 'preload-links-boolean',
775
+ pattern: /\bpreloadLinks\s*:\s*(?:true|false)\b/g,
776
+ message:
777
+ '`preloadLinks` takes link preload strategies, not a boolean, and preloads nothing by default. Hover/focus warming is preloadLinks: intentPreload() (from "@solidjs/router"); to disable it, omit the option.',
778
+ },
770
779
  {
771
780
  id: 'history-nav',
772
781
  pattern: /\b(?:history\.pushState|history\.replaceState|window\.location\.href\s*=)/g,
@@ -71,7 +71,9 @@ Steps:
71
71
  automatic `aria-current` / `data-active` attributes (+ CSS) — `data-pending` is
72
72
  opt-in via `createRouter({ links: pendingLinks })` — or
73
73
  `useLinkState` / `useIsRouting`; hand-rolled query parsing instead of
74
- `useSearchParams`; <!-- upstream:pending-links-opt-in -->
74
+ `useSearchParams`; <!-- upstream:pending-links-opt-in --> route `preload`
75
+ functions written to run on hover in a router without `preloadLinks: intentPreload()`
76
+ (link preloading is opt-in; a boolean `preloadLinks` preloads nothing) <!-- upstream:preload-links-opt-in -->;
75
77
  - search inputs bound to a held value without `latest`, effect-based debounce, fully
76
78
  controlled forms without `name`s, `{ success: false }` result objects instead of
77
79
  thrown failures, or router forms without server-side validation (`throw respond(...)`)
@@ -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` — return the cleanup; `onCleanup`, memo/effect creation, and `flush()` throw inside the `onSettled` callback in dev. Creating a memo/effect/root inside an effect's `apply` callback throws in dev too (`[PRIMITIVE_IN_EFFECT_CALLBACK]`) — create it in the body or compute phase. <!-- upstream:primitive-in-effect-callback --> 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 — markup types are `JSX.Element` from `@solidjs/web` or `Element` from `solid-js` (alias it `SolidElement` next to DOM code) <!-- upstream:jsx-namespace-absent -->. 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: "client"` on the memo/signal/derived store, not a `clientOnly` component split. Router link state is `aria-current`/`data-active` + CSS (`data-pending` is opt-in via `createRouter({ links: pendingLinks })`; `useLinkState`/`useIsRouting` in JSX) <!-- upstream:pending-links-opt-in -->; 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) <!-- upstream:async-store-setter-throws -->, and a held write needs `isPending`/`latest`/optimistic/`affects` feedback or dev + attribution reports `[SILENT_HOLD]`. Error monitoring is `configureClientErrors` / `configureServerErrors` (or per root `render(fn, root, undefined, { onError })` — options are the 4th argument), 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()`. <!-- upstream:dynamic-deprecated --> Observability is on by default in development: whenever a change touches stores, lists, async, actions, or effects, keep the dev console free of `[CODE]` findings and run an attribution session (`/__solid/diagnostics` `begin` → interact → `whyDidRun` / `costs` → `end` with `@solidjs/diagnostics` installed, otherwise `isDev`-guarded `attribution.enable({ log: false })` + `why()` / `costs()`) before finishing. `vite dev` is always the dev build; `solid({ observe: true })` is a production opt-in.
8
+ - Solid 2.0 shipped recently and breaks with 1.x — never trust pre-2.0 Solid docs, tutorials, or training knowledge. Verify APIs against https://v2-rebuild--solid-docs-v2.netlify.app/llms.txt (markdown mirror of https://v2.solidjs.com/, which blocks non-browser fetchers). If the project has `@solidjs/router`, server functions, or `@solidjs/meta`, use those official pages (see the skill's official-docs index) — not React Router, Next.js, SolidStart 1.x, or Solid Router 0.x/1.x JSX `<Route>`/`<A>`. Core `action` from `solid-js` is a generator transaction; router `action`/`query` from `@solidjs/router` are POST forms and a read cache. Core `refresh(source)`, router `revalidate(key)`, and server-function `return reload(...)` are three different APIs. Pass the accessor to `isPending(user)`, not `isPending(user())`. Pass async values as values (`user={user()}`) — JSX props are lazy; types stay `User`, not `Promise<User>`. `<Loading>` wraps the read, not the memo. Nested fetches run in parallel (components do not suspend). `isPending` is a per-expression question (`isPending(() => props.story)` / `isPending(selectedId)`), not a global spinner; `latest(selectedId)` is opt-in so highlight and content stay consistent by default. `<Errored>` heals when the source succeeds. Pass a function to `render`/`hydrate`: `render(() => <App />, root)`. Component teardown is `onSettled`, not `onCleanup` — return the cleanup; `onCleanup`, memo/effect creation, and `flush()` throw inside the `onSettled` callback in dev. Creating a memo/effect/root inside an effect's `apply` callback throws in dev too (`[PRIMITIVE_IN_EFFECT_CALLBACK]`) — create it in the body or compute phase. <!-- upstream:primitive-in-effect-callback --> 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 — markup types are `JSX.Element` from `@solidjs/web` or `Element` from `solid-js` (alias it `SolidElement` next to DOM code) <!-- upstream:jsx-namespace-absent -->. 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: "client"` on the memo/signal/derived store, not a `clientOnly` component split. Router link state is `aria-current`/`data-active` + CSS (`data-pending` is opt-in via `createRouter({ links: pendingLinks })`; `useLinkState`/`useIsRouting` in JSX) <!-- upstream:pending-links-opt-in -->; link preloading is opt-in too (`preloadLinks: intentPreload()` for hover/focus; none by default) <!-- upstream:preload-links-opt-in -->; 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) <!-- upstream:async-store-setter-throws -->, and a held write needs `isPending`/`latest`/optimistic/`affects` feedback or dev + attribution reports `[SILENT_HOLD]`. Error monitoring is `configureClientErrors` / `configureServerErrors` (or per root `render(fn, root, undefined, { onError })` — options are the 4th argument), 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()`. <!-- upstream:dynamic-deprecated --> 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.
@@ -397,7 +397,10 @@ Claude Code) for full patterns, decision tables, and official documentation URLs
397
397
  `createMemo(() => getProduct(props.params.id))`. `props.data` is whatever `preload`
398
398
  returned when the route first matched: it does not follow param changes (and an async
399
399
  `preload` makes it a Promise), so return a value only for data that stays fixed while
400
- the route is matched. One router instance —
400
+ the route is matched. Link warming is opt-in: route code and `preload` run ahead of
401
+ the click only with a strategy — `createRouter({ routes, preloadLinks: intentPreload() })`
402
+ for hover/focus (none by default; a boolean `preloadLinks` only warns in dev).
403
+ <!-- upstream:preload-links-opt-in --> One router instance —
401
404
  Solid Router does not support nested `<Router>` / nested `createRouter`.
402
405
  In-app navigation is `useNavigate` or `<a href={Router.paths...}>`, not
403
406
  `window.location` / `history.pushState`. Link state needs no component code:
@@ -519,7 +522,7 @@ directory (default `src/`).
519
522
  | `startTransition`, `useTransition` | automatic held updates + `isPending` |
520
523
  | `onError` / `catchError` | `<Errored>` or effect bundle `{ effect, error }` |
521
524
  | `from(...)` / `observable(...)` (the solid-js helpers) | inbound: async iterable from a memo; outbound: split effect |
522
- | `createDynamic(...)`, `<Dynamic component={...}>` (deprecated in 2.0) <!-- upstream:dynamic-deprecated --> | `dynamic(source)` from `@solidjs/web` — `dynamicComponent(source)` when the source only answers with a component <!-- upstream:dynamic-component-export --> |
525
+ | `createDynamic(...)`, `<Dynamic component={...}>` (deprecated in 2.0) <!-- upstream:dynamic-deprecated --> | `dynamic(source)` from `@solidjs/web` — `dynamicComponent(source)` from `solid-js` when the source only answers with a component <!-- upstream:dynamic-component-export --> |
523
526
  | `renderToStringAsync(...)` | `await renderToStream(() => <App />)` (one consumer: `pipe` / `pipeTo` / `readable`) |
524
527
  | `clearDelegatedEvents` | delete — delegated listeners are scoped to each render root |
525
528
  | `vite-plugin-solid` / `jsxImportSource: "solid-js"` | `@solidjs/vite-plugin` / `"jsxImportSource": "@solidjs/web"` |
@@ -534,12 +537,14 @@ directory (default `src/`).
534
537
 
535
538
  When unsure about any API, verify against the official Solid 2.0 docs — fetchable URLs are
536
539
  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 —
540
+ Solid 1.x or React memory. Some official pages still describe the previous release —
538
541
  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
542
+ (it needs `links: pendingLinks`) <!-- upstream:docs-pending-links-stale -->, the
540
543
  `createSignal` reference says a write to a writable derivation wins over a same-tick
541
544
  recompute (a source change in the same update now re-runs it with the write as
542
- `prev`). <!-- upstream:docs-derived-write-stale -->
545
+ `prev`) <!-- upstream:docs-derived-write-stale -->, and the performance guide and
546
+ attribution reference give `[WIDE_SCOPE_DEPS]` a threshold of 30 (the default
547
+ `wideDeps` is 200). <!-- upstream:docs-wide-deps-stale -->
543
548
 
544
549
  `createRenderEffect` exists in Solid 2 but is not a default. `storePath`
545
550
  (the 1.x path-setter migration helper) is no longer exported from `solid-js` as of
@@ -69,7 +69,7 @@ Most React `useEffect` code should NOT become `createEffect`:
69
69
  | Inspect, count, or iterate children | `children(() => props.children)` then `.toArray()` |
70
70
  | Code-split a component | `lazy(() => import("./X"))` read under `<Loading>` |
71
71
  | Named export from a lazy module | `lazy(() => import("./pages"), { export: "About" })` |
72
- | Pick a component/tag from reactive state | `dynamic(() => ...)` from `@solidjs/web` (stable identity; `<Dynamic>` is deprecated) <!-- upstream:dynamic-deprecated --> — `dynamicComponent(() => ...)` when the source only answers with a component, never a tag <!-- upstream:dynamic-component-export --> |
72
+ | Pick a component/tag from reactive state | `dynamic(() => ...)` from `@solidjs/web` (stable identity; `<Dynamic>` is deprecated) <!-- upstream:dynamic-deprecated --> — `dynamicComponent(() => ...)` from `solid-js` when the source only answers with a component, never a tag <!-- upstream:dynamic-component-export --> |
73
73
  | Overlay / modal | `<Portal>` — hoist async reads *above* the portal (reads inside start on the client) |
74
74
  | Async value used several layers down | Create the memo high; pass `value={memo()}` through intermediates (they do not wait); put `<Loading>` around the leaf read |
75
75
  | Nested child with its own fetch | Leave it nested — it runs in parallel. Sequential only when the second call needs the first response (`fetchAuthor(story().authorId)`) |
@@ -238,7 +238,10 @@ const About = lazy(() => import('./pages'), { export: 'About' });
238
238
  Never `React.lazy` / `<Suspense>`. The lazy component suspends through
239
239
  `<Loading>` on first render; `.preload()` starts the import early. Named
240
240
  exports must use `{ export: "About" }` — a `.then((m) => ({ default: m.About }))`
241
- wrapper hydrates incorrectly (the name is not a call-site literal).
241
+ wrapper hydrates incorrectly (the name is not a call-site literal). A module that is
242
+ not a component (a route table, a parser) takes `lazyModule(() => import("./x"))` from
243
+ `solid-js`: call it (or `.preload()`) for the cached promise, `.peek()` for the module
244
+ once it has landed; the server preloads it before hydration by module id.
242
245
 
243
246
  ### Dynamic component: `dynamic()` (canonical)
244
247
 
@@ -269,7 +272,8 @@ still shipped in 2.0. <!-- upstream:dynamic-deprecated --> Hoist a `dynamic()` p
269
272
  constant tag) instead.
270
273
 
271
274
  When the source only ever answers with a component — never a tag name — use
272
- `dynamicComponent()` instead: same contract and hydration shape minus the tag arm, so
275
+ `dynamicComponent()` from `solid-js` instead (`@solidjs/web` re-exports it): same
276
+ contract and hydration shape minus the tag arm, so
273
277
  a tag name is a compile error and a page whose only dynamic mounts are components
274
278
  retains no element runtime (`createElement` / `spread` / the namespace tables). It
275
279
  is the documented way to mount a server component: `dynamicComponent(() =>
@@ -548,29 +552,66 @@ and thrown errors reach `<Errored>`. Integration layers should return a plain ac
548
552
  ```tsx
549
553
  import { createMemo, onCleanup } from 'solid-js';
550
554
 
551
- function createSubscriptionQuery<T>(
552
- subscribe: (next: (value: T) => void, fail: (error: unknown) => void) => () => void,
555
+ function createSubscriptionQuery<A, T>(
556
+ args: () => A,
557
+ subscribe: (args: A, next: (value: T) => void, fail: (error: unknown) => void) => () => void,
558
+ name: string,
553
559
  ) {
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
- })());
560
+ return createMemo(() => {
561
+ const resolved = args(); // tracked, and read before any await: a change re-subscribes
562
+ let current: T | undefined;
563
+ let version = 0;
564
+ let failure: unknown;
565
+ let disposed = false;
566
+ let wake = () => {};
567
+ const unsubscribe = subscribe(
568
+ resolved,
569
+ (value) => { current = value; version += 1; failure = undefined; wake(); },
570
+ (error) => { failure = error; wake(); },
571
+ );
572
+ // Inside a computation, onCleanup runs before the next run and on disposal.
573
+ onCleanup(() => { disposed = true; unsubscribe(); wake(); });
574
+
575
+ return (async function* () {
576
+ let seen = 0;
577
+ while (!disposed) {
578
+ if (failure !== undefined) throw failure;
579
+ if (version > seen) { seen = version; yield current as T; continue; }
580
+ await new Promise<void>((resolve) => { wake = resolve; });
581
+ }
582
+ })();
583
+ }, { name }); // e.g. `query:${functionName}` — attribution tables name rows by it
571
584
  }
572
585
  ```
573
586
 
587
+ Open the subscription **inside the compute**, not around the memo. Then a change of
588
+ `args` closes the old subscription and opens the new one, `refresh(query)` and
589
+ `<Errored>`'s `reset` re-run the compute into a fresh subscription with the failure
590
+ cleared (a subscription opened outside the memo keeps its failure, so a retry never
591
+ recovers), and unmounting the owner closes it. Results that are snapshots (query
592
+ results) conflate to the latest value, as above; an event stream where every message
593
+ matters keeps a queue instead. A memo's compute runs **when it is created**, not on
594
+ first read (only `{ lazy: true }` defers it, and that also tears the memo down whenever
595
+ nothing observes it), so a subscription memo created in a provider connects even if
596
+ nothing reads it — resolve the client inside the compute and return a never-settling
597
+ iterable when there is none, rather than constructing it at module scope.
598
+
599
+ Identity-like subscriptions (the session, the current user) are app-wide state
600
+ (rule 11): create them **once**, in a provider at the root. One per page is torn down
601
+ on unmount and re-opened on mount, so every navigation starts with a fallback for one
602
+ round trip, and a page query that waits on it behind its own boundary is a sequential
603
+ pair — `[ASYNC_WATERFALL]` under attribution.
604
+
605
+ Clients that share one subscription per query-and-args and hand its current value to
606
+ every new subscriber (Convex, most reactive database clients) prefetch by subscribing
607
+ **early**: in the route's `preload` on link intent (`preloadLinks: intentPreload()` on
608
+ the router — without a strategy nothing preloads on hover <!-- upstream:preload-links-opt-in -->), held open for a short grace period
609
+ (seconds) and released at once on error, so a hover while signed out leaves no failure
610
+ for the page to inherit. Skip `intent === "initial"` there — on the first match the page
611
+ subscribes on mount anyway, and an identity-gated read would run before the session is
612
+ restored. Nothing is cached outside the client; the page's own subscription is the only
613
+ one left when the hold expires.
614
+
574
615
  Server-function `live()` sources are the same shape (async iterable → memo). Connection
575
616
  state is `source.onstatus` (`"connected"` / `"reconnecting"` / `"closed"`), not a field
576
617
  in the yielded value — see [Server functions](#server-functions--use-server).
@@ -799,6 +840,30 @@ Never `{todos().map((t) => <Row todo={t} />)}` and never `<For each={todos().map
799
840
  The `.map` form *renders*; it just rebuilds every row. `children().toArray().map` is
800
841
  fine — those nodes are already resolved.
801
842
 
843
+ Join and narrow **upstream of the list**, not per row. A row that narrows its own
844
+ lookup — `<For each={layout().cards}>{(card) => <Show when={byKey().get(card().key)}>…}` —
845
+ makes every row a reactive source of the list's insert effect (one per row; a plain
846
+ element or component row costs none), so every row change re-runs one effect that
847
+ tracks the whole list. Nothing flags it below 2000 rows: `[WIDE_SCOPE_DEPS]` skips that
848
+ insert pass by design (and defaults to 200 sources), and only the always-on
849
+ `[HUGE_FAN_IN]` reports it, from 2000 — so this is a review rule, not something a
850
+ diagnostics gate catches. <!-- upstream:per-row-show-insert-sources --> Build the joined
851
+ rows in one memo and iterate those; `flatMap` drops the misses without a `!`:
852
+
853
+ ```tsx
854
+ const cards = createMemo(() => {
855
+ const byKey = new Map(props.matches.map((m) => [m.key, m]));
856
+ return layout().cards.flatMap((card) => {
857
+ const match = byKey.get(card.key);
858
+ return match === undefined ? [] : [{ card, match }];
859
+ });
860
+ }, { name: "cards" });
861
+
862
+ <For each={cards()} keyed={(row) => row.card.key}>
863
+ {(row) => <MatchCard match={row().match} x={row().card.x} />}
864
+ </For>
865
+ ```
866
+
802
867
  `<Repeat from={start()} count={20}>{(index) => ...}</Repeat>` renders by absolute index with
803
868
  no array diffing — use for fixed slot counts and virtual scrolling windows.
804
869
 
@@ -1075,12 +1140,22 @@ write and `flush()`: `const { setA, b, dispose } = createRoot((dispose) => { …
1075
1140
  the test — `createRouter({ routes, history: memoryHistory("/orders/42") })` — because
1076
1141
  Testing Library's `render(…, { location })` is typed but not implemented (1.0.0-beta.3). <!-- upstream:testing-library-location -->
1077
1142
 
1143
+ An end-to-end test that must wait for hydration of an SSR page waits on a marker set from
1144
+ `onHydrationEnd` (`solid-js`, `@experimental`): register it *after* `hydrate()`, e.g.
1145
+ `onHydrationEnd(() => { document.documentElement.dataset.hydrated = ""; })`. It fires when
1146
+ the hydration phase ends — including when a module preload failed and the markup was left
1147
+ non-interactive — so it is a test-tooling marker, never a branch for what the app renders
1148
+ (`isHydrating()` / `onSettled` own that). Outside hydration it runs in the next microtask.
1149
+ <!-- upstream:hydration-end-experimental -->
1150
+
1078
1151
  `jsxImportSource` for tests and app code is `"@solidjs/web"`, not `"solid-js"`.
1079
1152
  Vite plugin is `@solidjs/vite-plugin`, not `vite-plugin-solid`.
1080
1153
 
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.
1154
+ To turn those dev diagnostics into a hard gate on every test, capture them per test
1155
+ with `@solidjs/diagnostics` — see
1156
+ [Gating every test on diagnostics](#gating-every-test-on-diagnostics). Patching
1157
+ `console.warn` sees only what the console reporter prints, and none of the
1158
+ attribution-tier findings or the hold tables.
1084
1159
 
1085
1160
  ### Dev diagnostics and attribution
1086
1161
 
@@ -1208,7 +1283,9 @@ expect(artifact).toHaveNoDiagnostics();
1208
1283
  expect(artifact).toHaveNoSilentHolds();
1209
1284
  ```
1210
1285
 
1211
- `toHaveNoDiagnostics` fails on any coded warning (`info` findings excluded);
1286
+ `toHaveNoDiagnostics` fails on every captured finding, `info` ones included
1287
+ (`[FALLBACK_FLASH]`, `[OPTIMISTIC_REVERTED]`, …) — its `allow` list names codes to
1288
+ tolerate;
1212
1289
  `toStayWithinRerunBudget` / `toHaveNoWaste` catch recompute regressions. The
1213
1290
  `agent-loops` skill the package ships
1214
1291
  (`node_modules/@solidjs/diagnostics/skills/agent-loops/SKILL.md`) documents the agent
@@ -1216,6 +1293,101 @@ verification loops: capture until the channel is quiet, scenario budgets as the
1216
1293
  definition of done, and hold/latency gates. See the debugging-reactivity guide in
1217
1294
  [references/official-docs.md](references/official-docs.md).
1218
1295
 
1296
+ ### Gating every test on diagnostics
1297
+
1298
+ Solid grades its findings `info` / `warn` / `error` and offers no switch to escalate
1299
+ them, so "diagnostics are errors" is enforced in tests. The shape that holds up is a
1300
+ setup file that wraps **every** test body in one capture, so a test written by someone
1301
+ who never heard of the engine is gated anyway:
1302
+
1303
+ ```ts
1304
+ // src/test-setup.ts — listed in the vitest project's `setupFiles`
1305
+ import { afterEach, beforeEach } from 'vitest';
1306
+ import { flush } from 'solid-js';
1307
+ import { captureArtifact, expectNoDiagnostics, expectNoSilentHolds } from '@solidjs/diagnostics';
1308
+
1309
+ let end: (() => void) | undefined;
1310
+ let capture: ReturnType<typeof captureArtifact<void>> | undefined;
1311
+ export const roots: (() => void)[] = []; // a `mount()` helper pushes each render's dispose
1312
+
1313
+ beforeEach((context) => {
1314
+ if (capture) throw new Error('tests under the gate must run sequentially');
1315
+ capture = captureArtifact<void>(() => new Promise<void>((resolve) => { end = resolve; }), {
1316
+ scenario: context.task.name,
1317
+ attribution: { hotTime: false, fallbackFlashes: false }, // wall-clock: see below
1318
+ });
1319
+ });
1320
+
1321
+ afterEach(async () => {
1322
+ for (const dispose of roots.splice(0).reverse()) dispose();
1323
+ flush();
1324
+ end?.();
1325
+ const pending = capture;
1326
+ capture = undefined;
1327
+ if (pending === undefined) throw new Error('this test ran outside the gate');
1328
+ const { artifact } = await pending; // close the capture before asserting anything
1329
+ expectNoDiagnostics(artifact);
1330
+ expectNoSilentHolds(artifact);
1331
+ });
1332
+ ```
1333
+
1334
+ What the shape has to get right:
1335
+
1336
+ - **A DOM environment** (`happy-dom` / `jsdom`) for every reactive test, `.test.ts`
1337
+ included. Under vitest's `node` environment the export conditions select Solid's
1338
+ **server build**: writes are inert (`[SERVER_WRITE]`), effects never run, and the
1339
+ attribution tables stay empty — a green test that proves nothing. Components that only
1340
+ exist in the server build (a `<html>` document shell) get their own `node` project
1341
+ rendered with `renderToString`, outside the gate.
1342
+ - **No HMR transform in the test project**: `solid({ refresh: { disabled: true } })`.
1343
+ The refresh transform wraps every component declaration in a memo, so the graph
1344
+ under test is not the graph production ships, and tests have no HMR to serve. (The
1345
+ wrappers are marked as plumbing, so the engine does not count them as sources.)
1346
+ <!-- upstream:per-row-show-insert-sources -->
1347
+ - **Two assertions, not one.** `[SILENT_HOLD]` is emitted only once a hold outlasts
1348
+ `holds.infoMs` (100ms), so a shorter unacknowledged hold is recorded and coded
1349
+ nowhere; `expectNoSilentHolds` asks the hold table, where no threshold applies.
1350
+ - **Wall-clock checks off where the clock means nothing.** `hotTime`'s 8ms budget is
1351
+ half a real browser frame; an emulated DOM spends more than that rendering a page, and
1352
+ a loaded runner several times more, so the failure names an innocent component.
1353
+ `[FALLBACK_FLASH]` measures backend latency, and a fake that settles at once flashes
1354
+ every fallback. Keep the count-based and structural checks (holds, waterfalls, list
1355
+ identity, wide scopes); measure time in a real browser.
1356
+ - **Sequential tests only.** The diagnostics channel and the engine are process-global,
1357
+ so under `test.concurrent` one test's finding lands in another's artifact — refuse the
1358
+ overlap rather than blame the wrong test.
1359
+ - **Dispose what the test mounted** before the capture closes. A root left alive re-runs
1360
+ on later writes, and its finding fails whichever test is open then.
1361
+ - **Settle deferred work** (`await resolve(...)`, `flush()`) before the body returns: an
1362
+ un-awaited timer or promise lands in the next test's capture, or in none. Module scope
1363
+ runs before the first capture opens — keep fixtures there, never a render.
1364
+ - **The app's own `attribution.enable()` stands down under a harness**
1365
+ (`if (import.meta.env.VITEST) return;`, and in the browser gate's mode). A hold taken
1366
+ while the engine is enabled opens a fresh window over `history()` / `costs()` /
1367
+ `feedback()`, which erases what the capture recorded so far, and options combine by
1368
+ the most demanding value per key, so the app's default hold re-arms every threshold
1369
+ the gate turned off. <!-- upstream:attribution-hold-window -->
1370
+ - **A test whose subject is a finding requires it** (`expectDiagnostic(artifact, code)`
1371
+ and then `allow` it), so the test fails when it stops provoking the code instead of
1372
+ decaying into a mute. Never `allow` a finding about real code.
1373
+ - **Fixtures above the threshold** for scale-dependent codes. `[WIDE_SCOPE_DEPS]` fires
1374
+ at 200 dependencies by default, `[HUGE_FAN_OUT]` / `[HUGE_FAN_IN]` and
1375
+ `[HOT_SCOPE_RERUNS]` at their own counts, so a fixture of three rows cannot falsify a
1376
+ per-row cost. When a gate is meant to catch one, give it a fixture past the code's
1377
+ threshold (or a lower threshold in the capture's `attribution` options) and check it
1378
+ fails with the defect re-injected. A per-row cost in a list's own insert pass is
1379
+ outside `[WIDE_SCOPE_DEPS]` entirely (see [Lists](#lists-for-child-signatures-per-keying-mode)).
1380
+
1381
+ An emulated DOM cannot see everything, so keep one **real-browser gate** for the main
1382
+ path: `captureBrowserArtifact(page, interact, { scenario })` from
1383
+ `@solidjs/diagnostics/playwright` drives the same capture through the bridge the Vite
1384
+ plugin injects into `vite dev`, and returns the same artifact, so one verdict function
1385
+ judges both. Serve that dev server in a dedicated mode with `refresh: { disabled: true }`
1386
+ and `performanceTracks: false`: the tracks paint every record from inside the scopes the
1387
+ engine times, enough to push a page's first render past the 8ms `[HOT_SCOPE_TIME]`
1388
+ budget. The wall-clock thresholds mean something there — except `fallbackFlashes`
1389
+ against a backend on loopback.
1390
+
1219
1391
  ### Development loop: self-diagnose before finishing
1220
1392
 
1221
1393
  Enable observability in development **by default** and lean on it. The dev build
@@ -1436,7 +1608,7 @@ Session location is `useLocation` / `useParams`,
1436
1608
  not `Router.paths`.
1437
1609
 
1438
1610
  **Route `preload` starts reads; the page reads them in a memo.** `preload` runs when
1439
- the route matches (and on link hover), so it should kick off the query and return
1611
+ the route matches (and ahead of a click under a `preloadLinks` strategy <!-- upstream:preload-links-opt-in -->), so it should kick off the query and return
1440
1612
  nothing; the component reads the same query through a memo that tracks the params:
1441
1613
 
1442
1614
  ```tsx
@@ -1484,15 +1656,27 @@ data that stays fixed while the route is matched. The factory `preload` on
1484
1656
  undefined` per key) — wrap a route in `defineRoute` when `preload` or the component
1485
1657
  needs `params.id: string`.
1486
1658
 
1487
- Link warming: `preload="false"` skips that link's *data* preload; `preloadLinks: false`
1488
- disables automatic link preloading. Intent values are `"initial"` / `"navigate"` /
1489
- `"native"` / `"preload"`. One router per app.
1659
+ Link warming is **opt-in**: `createRouter({ routes, preloadLinks: intentPreload() })`
1660
+ warms a link's route code and runs its `preload` on hover (after a short delay), focus
1661
+ and touch. Without `preloadLinks` nothing preloads ahead of the click. The other
1662
+ strategies, all from `@solidjs/router` and combinable as an array: `tapPreload()`
1663
+ (pointerdown), `viewportPreload()` and `eagerPreload()` (code only by default, for links
1664
+ marked `preload="viewport"` / `preload="eager"`, or every link with `{ all: true }`).
1665
+ `preload="false"` on an anchor opts that link out; `usePreloadRoute()` warms a route by
1666
+ hand. A boolean `preloadLinks` (the old on/off switch) only warns in dev and preloads
1667
+ nothing. <!-- upstream:preload-links-opt-in --> Intent values are `"initial"` /
1668
+ `"navigate"` / `"native"` / `"preload"`. One router per app.
1490
1669
 
1491
1670
  Link state needs no component code: the router sets `aria-current="page"` (same path
1492
1671
  and same query — parameter order and hash aside), `data-active` (exact or descendant
1493
1672
  on the path only — the `/` link is active only on exact match), and, with the opt-in
1494
1673
  `createRouter({ links: pendingLinks })` plugin, `data-pending` (in-flight navigation
1495
- target) on the anchors it handles; style them in CSS. Highlight nav links on
1674
+ target) on the anchors it handles; style them in CSS. The plugin is also a *reader* of
1675
+ the in-flight navigation: without it, or a `useLinkState().pending` / `isPending` read of
1676
+ the location somewhere on screen, nothing reads it, so every navigation that waits on a
1677
+ lazy page or its first data is an unacknowledged hold (`[SILENT_HOLD]` past 100ms,
1678
+ shorter ones only in `toHaveNoSilentHolds`). Create the router with
1679
+ `links: pendingLinks` by default, even when no stylesheet uses `data-pending` yet. <!-- upstream:pending-links-opt-in --> Highlight nav links on
1496
1680
  `data-active`, not `aria-current`: a nav link to `/products` is not current on
1497
1681
  `/products?page=2`. An `aria-current` you write
1498
1682
  yourself is yours — the router only manages the attribute on links where it set it.
@@ -1947,6 +2131,7 @@ how to reuse. Prefer the form on the right.
1947
2131
  | `<form method="get" action={update.url}>` for a mutation | GET forms only for idempotent search; mutations are POST |
1948
2132
  | `window.location.href = ...` / `history.pushState` | `useNavigate()` or `<a href={Router.paths...}>` |
1949
2133
  | `class={{ active: location.pathname === "/about" }}` hand-rolled link state | CSS on `aria-current` / `data-active` (+ `data-pending` via `links: pendingLinks`); `useLinkState` in JSX, `useIsRouting()` for progress bars <!-- upstream:pending-links-opt-in --> |
2134
+ | `createRouter({ routes, preloadLinks: true })` (or `false`) | `preloadLinks: intentPreload()` for hover/focus warming; omit the option for none — it takes strategies and a boolean preloads nothing <!-- upstream:preload-links-opt-in --> |
1950
2135
  | `new URLSearchParams(location.search)` hand-rolled query parsing | `useSearchParams()` (setter merges, no scroll); typed via a route `search` schema + `useSearchParams(Router.paths.search)` |
1951
2136
  | `Router.paths.about()` / string-concatenated URLs | static nodes are properties (`Router.paths.about`); calls bind params, then search object and hash |
1952
2137
  | `httpStatus(404)` in a click handler | call it bare in the component / error-fallback body (a scope declaration, not a mutation) |
@@ -1967,6 +2152,8 @@ how to reuse. Prefer the form on the right.
1967
2152
  | `Object.assign({}, props)` / expecting `merge` to skip `undefined` like 1.x `mergeProps` | `omit` / `merge` — explicit `undefined` **overrides** (Object.assign) |
1968
2153
  | `isPending(user())` / `latest(user())` | `isPending(user)` / `latest(user)` — pass the accessor (or `() => user()`). Calling it first evaluates the read before the helper runs |
1969
2154
  | `isPending(...)` as the first-load spinner | `<Loading>` owns first flight; `isPending` is the *refetch* indicator after a settled answer exists |
2155
+ | `<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 --> |
2156
+ | 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
2157
  | a held write with no feedback anywhere | pair it with `isPending()` / `latest()` / an optimistic value / `affects()` — otherwise dev + attribution reports `[SILENT_HOLD]` |
1971
2158
  | `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
2159
  | `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 +2323,10 @@ Known stale pages — this skill is verified against the installed release and w
2136
2323
  - `reference/solid-js/reactivity/create-signal` says a write to a writable derivation
2137
2324
  wins over a same-tick recompute; a source change in the same update re-runs the
2138
2325
  function, which receives the write as `prev`. <!-- upstream:docs-derived-write-stale -->
2326
+ - `guides/performance` ("tracks 30 or more sources") and the attribution reference
2327
+ (`wideDeps`, "default 30") give the old `[WIDE_SCOPE_DEPS]` threshold; the default is
2328
+ 200, and a list's insert pass is exempt from the check (see
2329
+ [Lists](#lists-for-child-signatures-per-keying-mode)). <!-- upstream:docs-wide-deps-stale -->
2139
2330
 
2140
2331
  **Never** consult Solid 1.x sources (docs.solidjs.com, pre-2.0 tutorials, old Stack Overflow).
2141
2332
  Solid 2.0 is a breaking rewrite; 1.x answers are wrong here. The banned-API table is in the
@@ -2215,6 +2406,10 @@ always-applied rules installed alongside this skill.
2215
2406
  `feedback`) whenever the change touched stores, lists, async, actions, or
2216
2407
  effects. Name interrogated scopes (`{ name: "total" }`). `observe: true` is a
2217
2408
  production opt-in, not a dev step.
2409
+ - [ ] Tests that render run under a DOM environment with the HMR transform off and a
2410
+ per-test capture asserting `expectNoDiagnostics` + `expectNoSilentHolds`; app code
2411
+ does not call `attribution.enable()` under the harness. List rows do not narrow
2412
+ themselves with a per-row `<Show>` (no diagnostic catches it).
2218
2413
  - [ ] If the project has a router: `createRouter({ routes })`, not JSX `<Route>` / `<A>`.
2219
2414
  One instance, no nested `<Router>`. Navigate with `useNavigate` / `<a href>`, not
2220
2415
  `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.22.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",