solid2-agent-kit 0.8.1 → 0.11.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.
@@ -70,7 +70,10 @@ Most React `useEffect` code should NOT become `createEffect`:
70
70
  | Named export from a lazy module | `lazy(() => import("./pages"), { export: "About" })` |
71
71
  | Pick a component/tag from reactive state | `dynamic(() => ...)` from `@solidjs/web` (stable identity; prefer over `<Dynamic>`) |
72
72
  | Overlay / modal | `<Portal>` — hoist async reads *above* the portal (reads inside start on the client) |
73
+ | 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 |
74
+ | 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)`) |
73
75
  | Browser-only widget (charts, maps, `window`) | `clientOnly(() => import("./Chart"))` from `@solidjs/web` (`{ lazy: true }` defers the import until first render) |
76
+ | Browser-only *value* (`localStorage`, viewport size) | `createMemo(..., { ssrSource: "client" })` — per-value policy at the data source, no component split (`"hybrid"` when server data mixes with client signals) |
74
77
  | Server vs browser branch | `isServer` / `isDev` from `@solidjs/web` (build-time constants), not `typeof window` |
75
78
  | SSR-stable `id` / `for` / `aria-*` pairing | `createUniqueId()` |
76
79
 
@@ -221,7 +224,9 @@ createEffect(
221
224
  Reads inside `apply` do not track. Extract every needed reactive value in `compute`
222
225
  (e.g. `() => ({ name: user.name, role: user.role })`). Passing a store proxy into `apply`
223
226
  and reading `user.name` there *runs once* and never retriggers. Compute-phase errors can be
224
- intercepted with the bundle form `createEffect(compute, { effect, error })`.
227
+ intercepted with the bundle form `createEffect(compute, { effect, error })`. Skip the
228
+ initial run with `{ defer: true }` — the `on(deps, fn, { defer: true })` replacement;
229
+ the effect first runs on the next change.
225
230
 
226
231
  A two-phase effect whose apply only calls a local signal setter is "state + effect" in
227
232
  disguise — formally legal Solid 2, still rule 4 (`solid2-kit check` flags it). The reset
@@ -245,44 +250,117 @@ const [signInError, setSignInError] = createSignal<string | null>(() => {
245
250
  });
246
251
  ```
247
252
 
248
- ### Async data
253
+ The writable form spells exactly this: derive from the source, allow a local
254
+ override, reset when the source changes. (React needs `useState(props.x)` plus a
255
+ `previousX` comparison reset during render for the same behavior.)
256
+
257
+ ### Async data — fetch high, block low
258
+
259
+ Creating and reading an async value are the same as sync. **Consuming** (the
260
+ await) is a DX choice; **blocking** (the boundary) is a UX choice. Solid
261
+ unwelds them: where you create the memo is performance, where `<Loading>`
262
+ wraps the *read* is design, and neither is an architecture decision. JSX
263
+ component props are lazy (**passing isn't reading**), so intermediates do
264
+ not wait. Nested components mount immediately — they do not suspend — so
265
+ their fetches run in parallel. Making a value remote should touch the memo
266
+ and the boundary, not every file on the path, and should not change prop
267
+ types to `Promise<User>` or `Accessor<User>`.
249
268
 
250
269
  ```tsx
251
- import { Errored, For, Loading, createMemo, createSignal } from 'solid-js';
270
+ import { Errored, Loading, createMemo, createSignal, isPending } from 'solid-js';
252
271
 
253
- const [query, setQuery] = createSignal('');
254
- const results = createMemo(async () => {
255
- const value = query().trim();
256
- if (!value) return [];
257
- return api.search(value);
258
- });
272
+ function App() {
273
+ const [selectedId, setSelectedId] = createSignal(1);
274
+ const story = createMemo(() => fetchStory(selectedId()));
259
275
 
260
- return (
261
- <Errored fallback={(error) => <p>{String(error())}</p>}>
262
- <Loading fallback={<p>Searching...</p>}>
263
- <ul><For each={results()}>{(r) => <li>{r.title}</li>}</For></ul>
264
- </Loading>
265
- </Errored>
266
- );
276
+ return (
277
+ <Errored fallback={(error, reset) => <ErrorFallback error={error} reset={reset} />}>
278
+ <StoryList selectedId={selectedId()} onSelect={setSelectedId} />
279
+ <main class={{ pending: isPending(selectedId) }}>
280
+ <Loading fallback={<DetailSkeleton />}>
281
+ <StoryDetail story={story()} storyId={selectedId()} />
282
+ </Loading>
283
+ </main>
284
+ </Errored>
285
+ );
286
+ }
287
+
288
+ function StoryDetail(props: { story: Story; storyId: number }) {
289
+ const byline = createMemo(() => `${props.story.author} · ${props.story.points} points`);
290
+ return (
291
+ <article class={{ stale: isPending(() => props.story) }}>
292
+ <h1>{props.story.title}</h1>
293
+ <p>{byline()}</p>
294
+ <Comments storyId={props.storyId} />
295
+ </article>
296
+ );
297
+ }
298
+ ```
299
+
300
+ `story()` is a `Story`, not `Story | undefined` — if the computation is running,
301
+ the value is there. No `?.`, no `!`. `<StoryDetail story={story()} />` is the
302
+ correct colorless form: the prop expression is a getter, so `StoryDetail` (and
303
+ every layout in between) renders immediately. The wait happens when
304
+ `props.story.title` is read, under that `<Loading>`.
305
+
306
+ ```tsx
307
+ // WRONG — extracting at the parent is a real read; child's <Loading> never sees it
308
+ const current = story();
309
+ return <StoryDetail story={current} />;
310
+
311
+ // WRONG — Promise / Accessor types on the path. Making story remote is not a
312
+ // type change for StoryLayout / StoryDetail.
313
+ function StoryDetail(props: { story: Promise<Story> }) { /* ... */ }
314
+ function StoryDetail(props: { story: Accessor<Story> }) { /* ... */ }
315
+
316
+ // Fine — pass the memo itself only when the child must refresh() that source.
317
+ // isPending / latest are questions: they work on the value prop too.
318
+ return <StoryDetail story={story} />;
267
319
  ```
268
320
 
269
- - The `<Loading>` boundary must be an owner ancestor of the **read** (`results()`), not of
321
+ - The `<Loading>` boundary must be an owner ancestor of the **read**, not of
270
322
  where the memo was created, and not of page chrome (header/nav) that should stay mounted.
271
- Boundary placement is purely a UX decision — it does not change when fetches start
272
- (no waterfalls: nested components set up and fetch in parallel). After first paint the
323
+ Lifting the fetch does **not** mean lifting the boundary — that is consume glued
324
+ back onto block. After first paint the
273
325
  boundary keeps settled content during refetch; `isPending(results)` (pass the accessor,
274
- not `isPending(results())`) is the
275
- indicator. Use `on={query()}` (the **value**, not the accessor `on={query}`) only when
276
- that identity should put the fallback back on screen.
326
+ not `isPending(results())`) is the indicator. Use `on={query()}` (the **value**, not the
327
+ accessor `on={query}`) only when that identity should put the fallback back on screen.
277
328
  Do not use `isPending` as the first-load spinner.
329
+ - **Nesting is not a waterfall.** `Comments` above fetches in parallel with `story`
330
+ because it reads `props.storyId` (already known), not `props.story`. Passing
331
+ `storyId={props.story.id}` *would* wait on the story fetch — that is a real data
332
+ dependency, same as `createMemo(() => fetchAuthor(story().authorId))`. Components
333
+ do not suspend; they run once. Only the expressions that read wait, so a parent
334
+ JSX read does not delay a child that does not need that value.
335
+ - `isPending` is a **per-expression question**, not state and not a global spinner.
336
+ Ask it anywhere: on the async source (`isPending(results)`), below it
337
+ (`isPending(() => props.story)`), on a derived memo (`isPending(byline)`), or
338
+ *above* the fetch but below the write (`isPending(selectedId)`), even when the
339
+ fetch lives in a child. You do not need to pass the memo accessor just to ask.
340
+ Granularity is the expression: `isPending(() => item.quantity)` (paired with
341
+ `affects(item, "quantity")`) or `isPending(() => todos.length)` dims only what
342
+ that read depends on. A hold with no feedback at all is `[SILENT_HOLD]` under
343
+ attribution — always pair a held write with one of these questions, an
344
+ optimistic value, or `affects()`.
345
+ - Default navigation **holds**. `selectedId()` and the old story stay put until
346
+ the new answer lands, so the highlight never points at the wrong content.
347
+ `latest(selectedId)` is the opt-in when the design wants the highlight to move
348
+ on click. Pair `class={{ pending: isPending(selectedId) }}` with a short CSS
349
+ `transition-delay` so fast swaps do not flash. Inputs that feed a request follow
350
+ the same rule: bind `value={latest(query)}` so a controlled search box reflects
351
+ keystrokes while its write is held (an uncontrolled input needs nothing).
278
352
  - Read every reactive input **before the first `await`** in an async computation. A read
279
353
  after `await` does not subscribe; production can sit pending with no retry.
280
354
  - `<Errored>` function fallbacks receive an error **accessor** and a `reset` callback:
281
- `fallback={(error, reset) => ...}`.
355
+ `fallback={(error, reset) => ...}`. The boundary **heals** when the source succeeds
356
+ again (refresh, live reconnect, input change) — it is graph status, not a terminal
357
+ React ErrorBoundary. `reset` retries the collected *data sources*, not a UI remount.
282
358
  - Refetch: `refresh(results)`. In-flight indicator: `isPending(results)` (or
283
359
  `isPending(() => results())`). Freshest in-flight value for a preview:
284
360
  `latest(results)`. Do not start `fetch` at component-body top level.
285
- - Coordinate reveal order of sibling `<Loading>` boundaries with `<Reveal order="sequential" | "together" | "natural">`.
361
+ - Coordinate sibling `<Loading>` reveal with `<Reveal>` (`order="sequential"` default,
362
+ or `"together"` / `"natural"`). `collapsed` (sequential only) suppresses tail
363
+ skeletons past the frontier so the page does not stack fallbacks as popcorn.
286
364
 
287
365
  **Never hand-roll these states.** No `[loading, setLoading]` signal, no
288
366
  `data() === undefined` branch, no `{ data, error }` signal pair — those are React/Solid 1.x
@@ -290,7 +368,12 @@ reflexes that bypass the async model and `solid2-kit check` flags them. If a val
290
368
  make it an async computation and read it under boundaries. Refetching is `refresh(source)`
291
369
  (typically as the last step of a mutation `action`, or a reload button) — never a
292
370
  counter/version signal read inside the computation to force re-runs; input-driven refetch is
293
- already automatic.
371
+ already automatic. No request counters or `AbortController`s for dedup/cancellation
372
+ either — superseded answers are dropped automatically and settled content holds;
373
+ debounce at the event handler (`onInput={debounce(...)}`), never as an effect copying
374
+ one signal into another. And throw unusable responses instead of returning
375
+ `{ success: false }` objects checked at every read — failures travel to `<Errored>`
376
+ like values.
294
377
 
295
378
  **Normalize thrown values once.** Thrown values are `unknown` in JavaScript, so define one
296
379
  shared `ErrorFallback` component per app — extract the message via `instanceof Error`, wire
@@ -334,21 +417,101 @@ function createSubscriptionQuery<T>(
334
417
  }
335
418
  ```
336
419
 
420
+ Server-function `live()` sources are the same shape (async iterable → memo). Connection
421
+ state is `source.onstatus` (`"connected"` / `"reconnecting"` / `"closed"`), not a field
422
+ in the yielded value — see [Server functions](#server-functions--use-server).
423
+
337
424
  ### Mutations: `action` + optimistic state
338
425
 
426
+ The synchronous mutation *is* the prediction. Optimistic UI is not a second copy of
427
+ state — it is an overlay the graph discards when the action settles (pass or fail).
428
+ There is no rollback to write. Put in-flight affordances on the record
429
+ (`pending?: boolean`); they vanish with the overlay.
430
+
431
+ Minimal shape — the durable value derives from `fn`, optimistic writes overlay it,
432
+ and `refresh` re-runs the derivation so the store reconciles against the server
433
+ (no separate `getMessages()` call):
434
+
339
435
  ```tsx
340
436
  import { action, createOptimisticStore, refresh } from 'solid-js';
341
437
 
342
- const [todos, setTodos] = createOptimisticStore(fetchTodos, []);
438
+ function createMessages() { // call from a component — not module scope (SSR shares modules)
439
+ const [messages, setMessages] = createOptimisticStore(() => getMessages(), []);
343
440
 
344
- const addTodo = action(function* (todo: Todo) {
345
- setTodos((draft) => { draft.push(todo); }); // optimistic, auto-reverts on failure
346
- yield saveTodo(todo); // yield promises to stay in the transaction
347
- refresh(todos);
348
- });
349
- // invoke from an event handler: onClick={() => void addTodo(newTodo)}
441
+ const addMessage = action(function* (message: Message) {
442
+ setMessages((m) => { m.push(message); });
443
+ yield saveMessage(message);
444
+ refresh(messages);
445
+ });
446
+ return [messages, { addMessage }] as const;
447
+ }
350
448
  ```
351
449
 
450
+ Mutate the draft (`m.push(message)`), never spread-rebuild (`[...m, message]`) —
451
+ stores track writes finely, so only the changed part updates. When the action
452
+ settles, the overlay is discarded and the refreshed server data is authoritative;
453
+ if it agrees with the prediction, reconciliation finds no differences and nothing
454
+ re-renders. Derived reconciliation keys by `"id"` by default — pass `{ key: ... }`
455
+ only when the identity field differs.
456
+
457
+ Layered form, when per-row UI must survive overlay discard (a recoverable error
458
+ with Retry on that row only):
459
+
460
+ ```tsx
461
+ import { action, createOptimisticStore, refresh } from 'solid-js';
462
+
463
+ type Todo = { id: string; title: string; completed: boolean; pending?: boolean; error?: { completed: boolean } };
464
+
465
+ function createTodos() { // call from a component — not module scope (SSR shares modules)
466
+ const errors = new Map<string, { completed: boolean }>();
467
+ const [todos, setTodos] = createOptimisticStore<Todo[]>(async () => {
468
+ const current = await api.list();
469
+ return current.map((todo) => {
470
+ const error = errors.get(todo.id);
471
+ return error ? { ...todo, error } : todo;
472
+ });
473
+ }, []);
474
+
475
+ const toggleTodo = action(function* (id: string, completed: boolean) {
476
+ setTodos((draft) => {
477
+ const todo = draft.find((row) => row.id === id);
478
+ if (!todo) return;
479
+ todo.completed = completed;
480
+ todo.pending = true;
481
+ });
482
+ try {
483
+ yield api.toggle(id, completed);
484
+ errors.delete(id);
485
+ } catch {
486
+ errors.set(id, { completed });
487
+ } finally {
488
+ refresh(todos);
489
+ }
490
+ });
491
+ return [todos, { toggleTodo }] as const;
492
+ }
493
+ ```
494
+
495
+ Three layers, in this order: durable source → ephemeral UI that must survive
496
+ overlay discard (the `errors` map, folded in the projection) → optimistic overlay
497
+ (`pending`, the predicted `completed`). Consumers read one store. Catch
498
+ *expected* mutation failures in the action so a toggle error does not blank the
499
+ list through `<Errored>`; unhandled projection/render errors still reach the
500
+ boundary.
501
+
502
+ Going from a client store to the server is **additive**: same `setTodos`
503
+ calls, wrap each mutation in `action`, swap in `createOptimisticStore` and a
504
+ file of server functions. Do not rewrite `App.tsx` with loading/error branches,
505
+ and do not snapshot-the-cache / write-a-prediction / restore-on-error. A cache
506
+ library is not the productionizing step — the sync mutation already was the
507
+ prediction. (Router `query` or `@tanstack/solid-query` remain valid when the
508
+ project already uses them; they are not required to "make it real".)
509
+ Optimistic rows stay interactive — a not-yet-acked todo can still
510
+ be toggled. Do not disable the control until confirmation, and do not mutex
511
+ rapid clicks; `action` is the transaction (no half-mutation, no interleaved
512
+ list). An in-flight action does not freeze unrelated writes. Collecting failed
513
+ actions to replay is a design choice, not something the runtime requires.
514
+
352
515
  Ordinary signal/store writes inside an action are held until it settles. Never call
353
516
  `flush()` inside an action. Invoke actions from handlers, not from component/computation bodies.
354
517
  Do not set a `submitted` signal and watch it from an effect — that work belongs in the
@@ -356,11 +519,37 @@ handler or the `action`.
356
519
 
357
520
  `createOptimistic` / `createOptimisticStore` are for a tentative value during an
358
521
  active mutation, not a local editing session (use a writable derivation or a plain
359
- store for that).
522
+ store for that). A whole-form `createOptimistic(false)` saving flag is fine; prefer
523
+ `pending` on the record when the affordance is per item. The overlay is
524
+ discarded when the action settles — the confirmed store is the truth. Wrapping
525
+ the same store in server functions does not change that; do not add
526
+ snapshot/restore.
360
527
 
361
528
  **`yield`, not a bare `await`, is the transaction boundary.** `await api.save()` then
362
- `setTodos(...)` *runs*, but the write commits immediately and optimistic rollback is lost.
363
- Either `yield saveTodo(todo)` or, when you need a typed result, `const saved = await api.save(); yield; setTodos(...)`.
529
+ `setTodos(...)` *runs*, but the write commits immediately — it is not held as an
530
+ overlay. Either `yield saveTodo(todo)` or, when you need a typed result, `const saved = await api.save(); yield; setTodos(...)`.
531
+ Write the body as a generator — `function*` or `async function*`, never a plain
532
+ `async (...) =>` — because only `yield` re-enters the transaction: the runtime has no
533
+ hook into a plain `await` continuation, so even inside `async function*` an `await`
534
+ leaves the transaction until the next `yield` (`solid2-kit check` flags the
535
+ non-generator form).
536
+
537
+ When confirmation arrives outside the response — a fire-and-forget transport echoed
538
+ back on a live source — hold the action open with `yield until(predicate, { timeout })`:
539
+
540
+ ```tsx
541
+ const send = action(function* (text: string) {
542
+ const clientId = crypto.randomUUID();
543
+ setMessages((m) => { m.push({ clientId, text, pending: true }); });
544
+ yield socket.send({ clientId, text });
545
+ yield until(() => messages.some((m) => m.clientId === clientId), { timeout: 10_000 });
546
+ });
547
+ ```
548
+
549
+ The predicate reads authoritative state, so the optimistic row cannot satisfy it;
550
+ only the echo can. Timeout rejects with `TimeoutError` (strongly recommended on
551
+ droppable transports; abort via `signal`). Outside actions, `await until(() => user())`
552
+ waits for a value before continuing.
364
553
 
365
554
  Navigation-shaped updates do **not** need core `action`. A plain setter is enough:
366
555
  reads pull the async, and downstream async computeds hold previous values until the
@@ -513,6 +702,46 @@ do not `return () => cleanup`. Put owned setup in a **directive factory** (like
513
702
  `ResizeObserver`, third-party widgets, and native listener options (`capture` /
514
703
  `passive`) — Solid event props do not take those options.
515
704
 
705
+ ### Per-value SSR policy: `ssrSource`
706
+
707
+ `clientOnly` splits a *component*; `ssrSource` keeps one *value* off the server,
708
+ decided at the data source. `createMemo`, function-form `createSignal`, and derived
709
+ stores (`createStore(fn, seed)`, `createProjection`, `createOptimisticStore(fn, seed)`)
710
+ all accept `ssrSource: "server" | "hybrid" | "client"`:
711
+
712
+ ```tsx
713
+ import { Loading, createMemo } from 'solid-js';
714
+
715
+ function DraftPreview(props: { documentId: string }) {
716
+ const draft = createMemo(
717
+ () => localStorage.getItem(`draft:${props.documentId}`),
718
+ { ssrSource: 'client' },
719
+ );
720
+
721
+ return (
722
+ <section>
723
+ <h2>Saved draft</h2>
724
+ <Loading fallback={<p>Checking for a saved draft...</p>}>
725
+ <p>{draft() ?? 'No saved draft.'}</p>
726
+ </Loading>
727
+ </section>
728
+ );
729
+ }
730
+ ```
731
+
732
+ | Policy | Meaning | Use when |
733
+ |---|---|---|
734
+ | `"server"` (default) | Client adopts the serialized server value; compute does not re-run | Compute is deterministic from server-available inputs |
735
+ | `"hybrid"` | Client adopts the serialized value, then re-runs the compute to take over | Server data mixes with client signals (window size, user locale) |
736
+ | `"client"` | Server value skipped; compute runs after hydration as if first-mounted | Serialization is meaningless (`localStorage`, viewport) |
737
+
738
+ With `"client"`, the bare form suspends on the server and the nearest `<Loading>`
739
+ fallback renders into the HTML; a declared first value (`loadingValue` on
740
+ signal-family sources, `seedLoadingValue: true` on store-family sources) renders
741
+ provisional data with no boundary involvement instead. Filling the value in from
742
+ an `onSettled` callback remains a valid alternative; `NoHydration` / `Hydration`
743
+ split hydration *ownership* instead and do not express this policy.
744
+
516
745
  ## Scheduling and tests
517
746
 
518
747
  Signal/store writes outside a synchronous flush scope are **staged**; the reactive queue
@@ -529,6 +758,8 @@ Wait for an async expression to settle in tests: `await resolve(() => value())`.
529
758
  on a pending resource. `onSettled` is also a **single** fire (untracked): it is not
530
759
  `onMount` that re-runs, and it is not an effect. Run tests in dev mode first — Solid 2 emits diagnostics for top-level reactive reads,
531
760
  writes from owned scopes, and async reads outside `<Loading>`. Fix them; don't suppress.
761
+ A read at component-body top level reports `[STRICT_READ_UNTRACKED]` naming the
762
+ component — move the read into JSX, a memo, or an effect compute.
532
763
 
533
764
  Component tests use `@solidjs/testing-library`. Pass a **function** to `render` so
534
765
  the tree has an owner, and `flush()` after interactions that stage writes:
@@ -556,6 +787,60 @@ To turn those dev diagnostics into a hard gate, patch `console.warn` in the test
556
787
  file to rethrow unexpected warnings — then any top-level reactive read or unowned write
557
788
  an agent sneaks in fails the suite instead of scrolling by.
558
789
 
790
+ ### Dev diagnostics and attribution
791
+
792
+ Diagnostics come in two tiers, both dev-build only (production strips them and `DEV`
793
+ is `undefined` there). Always-on findings include misplaced reads
794
+ (`[STRICT_READ_UNTRACKED]`, `[PENDING_ASYNC_UNTRACKED_READ]`), misplaced writes
795
+ (`[REACTIVE_WRITE_IN_OWNED_SCOPE]`, `[FLUSH_IN_ACTION]`, `[SERVER_WRITE]`), and
796
+ `[ASYNC_OUTSIDE_LOADING_BOUNDARY]` when a tracked read has no boundary above it.
797
+ Each entry carries a stable `[CODE]`, what the runtime observed, and an owner chain
798
+ (`in <App> › <Cart> › <LineItem>`); never silence a code before understanding it.
799
+
800
+ Cost and responsiveness findings need the opt-in attribution engine, which records
801
+ every scope that re-ran, what changed to cause it, and how long it took:
802
+
803
+ ```ts
804
+ import { DEV } from 'solid-js';
805
+
806
+ DEV?.attribution.enable();
807
+ // reproduce the interaction, then ask why a scope ran:
808
+ for (const event of DEV!.attribution.why(total)) {
809
+ console.log(DEV!.attribution.format(event));
810
+ }
811
+ ```
812
+
813
+ While attribution is enabled, Solid also warns on its own about over-subscription,
814
+ chained async waterfalls (`[ASYNC_WATERFALL]`), memos whose fresh-but-equal output
815
+ never settles (`[UNSTABLE_MEMO_OUTPUT]`), effects that relay state
816
+ (`[EFFECT_RELAY_TEAR]`) or feed their own inputs (`[EFFECT_WRITES_OWN_SOURCE]`),
817
+ immutable updates inside stores (`[IMMUTABLE_UPDATE_IN_STORE]` — mutate the draft),
818
+ row-rebuilding lists (`[UNSTABLE_LIST_IDENTITY]` — key by id or reconcile into a
819
+ store), and silent holds (`[SILENT_HOLD]` — a write held on async work with no
820
+ `isPending()` / `latest()` reader, optimistic value, `affects()` mark, or effect that
821
+ ran while held; reported from 300ms as info, from 500ms as a warning). `DEV.attribution.costs()`,
822
+ `holds()`, and `feedback()` rank the session worst-first. Name the scopes you intend
823
+ to interrogate (`createMemo(..., { name: "total" })`); `DEV.attribution.subscriptions(scope)`
824
+ lists a scope's current dependencies to compare against what it uses.
825
+
826
+ For regression tests, `@solidjs/diagnostics` turns the same channels into assertions:
827
+
828
+ ```ts
829
+ import { captureArtifact } from '@solidjs/diagnostics';
830
+
831
+ const { artifact } = await captureArtifact(() => {
832
+ fireEvent.click(getByRole('button', { name: 'Add' }));
833
+ flush();
834
+ }, { scenario: 'add-item' });
835
+
836
+ expect(artifact).toHaveNoDiagnostics();
837
+ expect(artifact).toHaveNoSilentHolds();
838
+ ```
839
+
840
+ `toHaveNoDiagnostics` fails on any coded warning (`info` findings excluded);
841
+ `toStayWithinRerunBudget` / `toHaveNoWaste` catch recompute regressions. See the
842
+ debugging-reactivity guide in [references/official-docs.md](references/official-docs.md).
843
+
559
844
  ## Client mode: no server HTML, no hydration reflexes
560
845
 
561
846
  The `@solidjs/vite-plugin` default (client mode, the `bare` tier) serves an empty-body
@@ -568,7 +853,7 @@ deferred "safe" initial value is worse than unnecessary: if anything persists th
568
853
  (an effect writing `localStorage`), the placeholder **overwrites the user's stored
569
854
  preference** before the real value is adopted. Mismatch only becomes a concept in
570
855
  start/SSR mode, where `hydrate()` mounts onto server HTML — and the Solid answer there is
571
- `isServer` / `clientOnly`, still not React-style deferred adoption.
856
+ `isServer` / `clientOnly` / per-value `ssrSource`, still not React-style deferred adoption.
572
857
 
573
858
  ## App stack (only if the project has these packages)
574
859
 
@@ -593,6 +878,9 @@ DOM-specific `JSX` types (`JSX.IntrinsicElements`, `JSX.CSSProperties`) and
593
878
  with `start: true` / `ssr: true` / `serverFunctions: true` only when the
594
879
  project already uses start mode. Tiers are `bare` → `basic` (router) →
595
880
  `fullstack` (SSR + server functions); do not jump a tier without cause.
881
+ Under `start: true` the plugin owns entries, the document shell, and the build —
882
+ write `src/App.tsx`, not SolidStart-style `entry-client.tsx` / `entry-server.tsx`
883
+ or a hand-written `index.html`.
596
884
 
597
885
  ### Solid Router 2 — `createRouter`, not JSX `<Route>`
598
886
 
@@ -614,7 +902,7 @@ export default function App() {
614
902
  <Router>
615
903
  {(props) => (
616
904
  <>
617
- <a href={Router.paths()}>Home</a>
905
+ <a href={Router.paths}>Home</a>
618
906
  <main>{props.children}</main>
619
907
  </>
620
908
  )}
@@ -637,6 +925,27 @@ Link warming: `preload="false"` skips that link's *data* preload; `preloadLinks:
637
925
  disables automatic link preloading. Intent values are `"initial"` / `"navigate"` /
638
926
  `"native"` / `"preload"`. One router per app.
639
927
 
928
+ Link state needs no component code: the router sets `aria-current="page"` (exact
929
+ match), `data-active` (exact or descendant — the `/` link is active only on exact
930
+ match), and `data-pending` (in-flight navigation target) on the anchors it handles;
931
+ style them in CSS. For non-anchors, or state needed in JSX, `useLinkState(() => href,
932
+ { end })` returns the same three as accessors (`active` / `current` / `pending`);
933
+ `useIsRouting()` is the page-level pending flag for progress bars. Hand-rolled
934
+ `location.pathname === ...` comparisons are the wrong tool.
935
+
936
+ Build URLs from `paths`: static segments are properties (`Router.paths.about`, never
937
+ `Router.paths.about()`), parameterized segments are calls (`paths.products(id)`),
938
+ followed by an optional search object and hash (`paths.search({ q, page }, "results")`).
939
+ Nodes stringify in `href` / `navigate()` / `redirect()`, so call with no arguments
940
+ only when an API insists on a plain string.
941
+
942
+ Search params are the home for shareable state (filter, sort, page): `const [search,
943
+ setSearch] = useSearchParams()` — the setter merges and navigates without scrolling;
944
+ `""` / `undefined` / `null` removes a key. For typed values, put a synchronous
945
+ Standard Schema on the route's `search` field and pass the path node:
946
+ `useSearchParams(Router.paths.search)` (async schemas throw; a failing schema leaves
947
+ the raw strings rather than throwing).
948
+
640
949
  **Two different `action`s.** Core `action` from `solid-js` is a generator transaction
641
950
  (optimistic writes that span an async gap). Router `action` from `@solidjs/router` is
642
951
  a URL-addressable POST mutation (`useSubmissions`, `<form action={save} method="post">`).
@@ -661,6 +970,59 @@ Only POST forms are accepted. Bind extra args with `.with(id)` (they go in the
661
970
  action URL). `onSubmit={(e) => { e.preventDefault(); fetch(...) }}` *runs* and
662
971
  drops the no-JS form fallback. `useAction` is JS-only for the same reason.
663
972
 
973
+ Validated forms come in three passes sharing one HTML shape. Pass 1 (above) posts
974
+ uncontrolled inputs with browser `required` / `pattern` — it works with no JavaScript.
975
+ Pass 2 validates on the server, where the user cannot bypass it:
976
+
977
+ ```ts
978
+ import { redirect, respond } from '@solidjs/web';
979
+ import * as v from 'valibot';
980
+
981
+ const Address = v.object({
982
+ postalCode: v.pipe(v.string(), v.regex(/^[0-9]{5}$/, 'Enter a five-digit postal code')),
983
+ });
984
+ export type AddressIssues = Partial<Record<'postalCode', string>>;
985
+
986
+ export async function saveAddress(form: FormData) {
987
+ 'use server';
988
+ const parsed = v.safeParse(Address, Object.fromEntries(form));
989
+ if (!parsed.success) {
990
+ const issues: AddressIssues = {};
991
+ for (const issue of parsed.issues) {
992
+ const field = issue.path?.[0]?.key as keyof AddressIssues | undefined;
993
+ if (field && !issues[field]) issues[field] = issue.message;
994
+ }
995
+ throw respond({ issues }, { status: 400 });
996
+ }
997
+ await database.addresses.save(parsed.output);
998
+ return redirect('/checkout/shipping');
999
+ }
1000
+ ```
1001
+
1002
+ Keep server functions in `src/data/`, not under `src/routes` (with method discovery
1003
+ on, a `.ts` file there is an API route). `throw respond(...)` is intentional control
1004
+ flow — the status and value survive production sanitization, unlike a plain thrown
1005
+ `Error`. Pass 3 renders the messages inline from the recorded submission:
1006
+
1007
+ ```tsx
1008
+ import { action, useSubmissions } from '@solidjs/router';
1009
+
1010
+ const submitAddress = action(saveAddress);
1011
+ const submissions = useSubmissions(submitAddress);
1012
+ const issues = () => (submissions.at(-1)?.error as { issues?: AddressIssues } | undefined)?.issues ?? {};
1013
+
1014
+ <Show when={issues().postalCode}>{(message) => <p role="alert">{message()}</p>}</Show>
1015
+ ```
1016
+
1017
+ `issues()` is a plain derived function; `aria-invalid` on the input, `role="alert"`
1018
+ on the message. Redirect outcomes do not stay in the list; the no-JS outcome arrives
1019
+ via flash cookie as the same submission shape. Pending state is CSS on the automatic
1020
+ attribute — `form[aria-busy] button[type="submit"] { opacity: .6 }` — no component
1021
+ code. `submission.clear()` on dismiss/resubmit only, never from an effect (errors
1022
+ would flash and vanish). Live per-keystroke validation is a controlled input plus a
1023
+ memo message (`aria-live="polite"`), keeping `name` so `FormData` still works; leave
1024
+ the rest uncontrolled.
1025
+
664
1026
  **`query` is the read cache** (optional, only with the router). Wrap a read,
665
1027
  give it a stable name, and consume it through a Solid async primitive:
666
1028
 
@@ -689,7 +1051,8 @@ router mutation, `revalidate(...)` — not core `refresh()`, and not
689
1051
  refresh cached data: `return reload({ revalidate: "todos" })`). `live()` sources
690
1052
  update through the open stream; do not `revalidate` them. Replace SolidStart /
691
1053
  Router 0.x leftovers: no `createAsync`, `useSubmission` (singular), `cache()`,
692
- router `json()`, or `<FileRoutes />` (`fileRoutes(pageRoutes)` instead).
1054
+ router `json()`, or `<FileRoutes />` (`fileRoutes(pageRoutes)` from `@solidjs/router/fs`
1055
+ over `pageRoutes` from `virtual:file-routes` instead).
693
1056
 
694
1057
  ### Server functions — `"use server"`
695
1058
 
@@ -702,11 +1065,23 @@ export async function findUser(id: string) {
702
1065
  ```
703
1066
 
704
1067
  A function-level server function cannot close over component locals — pass
705
- values as arguments. Treat every argument as untrusted. Read trusted identity
1068
+ values as arguments. Treat every argument as untrusted — TypeScript does not
1069
+ cross the HTTP boundary, so do not invent tRPC / RPC type-gen;
1070
+ validate inside the function (a parse in the function body is that
1071
+ validation, not an RPC schema across the wire). These functions *are* the RPC — do not add an
1072
+ API-route file (or a Next `route.ts`) just to wrap `db.todos.insert`. API
1073
+ routes are for real HTTP endpoints (webhooks, third-party POST), not for the
1074
+ app's own RPC. Read trusted identity
706
1075
  from `getRequestEvent()`, never from a caller-supplied user id. During SSR the same
707
- reference runs in-process; in the browser it is HTTP. Mutations that should
1076
+ reference runs in-process (no HTTP); in the browser it is HTTP. Anything
1077
+ referenced only inside the `"use server"` body (db client, secrets) never
1078
+ reaches the client — the directive is the privacy boundary. Mutations that should
708
1079
  revalidate: `redirect` / `reload` from `@solidjs/web` (see the server-function
709
- guides). For 404 during SSR: `httpStatus(404)` from `@solidjs/web`.
1080
+ guides). Return or throw a redirect — guards read `if (!session?.userId) throw
1081
+ redirect("/sign-in")`. `redirect()` and `respond()` accept the same `revalidate`
1082
+ option as `reload()` when the mutation also changes location or returns a value. After a router/server mutation do **not** follow with a client `fetch`
1083
+ to "refresh the page" — `reload` / `revalidate` (and the router's single-flight
1084
+ response) close that loop. For 404 during SSR: `httpStatus(404)` from `@solidjs/web`.
710
1085
  `throw new Error("…")` is stripped to `"Internal Server Error"` in production —
711
1086
  use `markSafeError` or `respond(..., { status })` for intentional client-facing
712
1087
  failures. Do not `return Response.json(...)` from `"use server"` — that is
@@ -715,9 +1090,71 @@ caller unwraps. JSON-encodable arguments only unless `enableRichArguments()`
715
1090
  was called once in the client entry (`Date` / `Map` / `Set` throw without it).
716
1091
  `GET()` is only for idempotent reads (URLs leak into logs/history);
717
1092
  import it from `@solidjs/web/server-functions`, never `@solidjs/start`.
1093
+ Reads are function calls too: `GET(async (id) => { "use server"; ... })` is invoked
1094
+ as `await getUser(id)` — Solid owns the endpoint, serialization, and decoding, the
1095
+ database access stays on the server, and argument/return types flow to the caller.
1096
+ The HTTP method mirrors the operation (reads GET, mutations POST); when encoded
1097
+ arguments would exceed the URL limit the client falls back to a read-only POST.
1098
+ Cache GET reads with HTTP, not a hand-rolled Solid cache: responses default to
1099
+ `Cache-Control: no-store`, so supply the policy via `respond()` — the caller still
1100
+ receives the value; headers ride the transport.
1101
+
1102
+ ```ts
1103
+ import { respond } from '@solidjs/web';
1104
+ import { GET } from '@solidjs/web/server-functions';
1105
+
1106
+ export const fetchStory = GET(async (id: number) => {
1107
+ 'use server';
1108
+ const story = await db.stories.find(id);
1109
+ return respond(story, {
1110
+ headers: { 'cache-control': 'public, max-age=60, s-maxage=300' },
1111
+ });
1112
+ });
1113
+ ```
1114
+
718
1115
  `live()` yields **current state** (each yield replaces the last); do not treat
719
- it as an append-only event log, and hoist the memo so consumers share one
720
- connection.
1116
+ it as an append-only event log. Until the first yield it is unsettled
1117
+ (`<Loading>`), same as a Promise; later yields are updates — the fallback does
1118
+ not return.
1119
+ Wire status is a side channel on the **iterable the `live()` call returns**,
1120
+ not a field in the value and not a property of the memo:
1121
+
1122
+ ```ts
1123
+ const source = stockPrice("ACME");
1124
+ source.onstatus = (next) => setStatus(next);
1125
+ const price = createMemo(() => source);
1126
+ ```
1127
+
1128
+ Do not yield `{ price, connected: true }`. After the first successful connection,
1129
+ transient failures retry with exponential backoff; a 4xx is a definite rejection.
1130
+ There is no subscription API and no store wiring to configure. For a reactive
1131
+ argument, call `live()` inside the memo so the input is tracked:
1132
+ `createMemo(() => stockPrice(props.symbol))`. Every reader of that memo shares
1133
+ one connection — hoist it instead of calling `live()` at each site. Do not
1134
+ subscribe/unsubscribe in the component around `live()`. The `live()` generator
1135
+ may still subscribe to an upstream source.
1136
+
1137
+ **The graph continues across the wire.** An in-flight Promise serializes as a
1138
+ Promise and resolves in the client graph as it settles — including before the
1139
+ bundle has loaded. A `live()` source sends its first value in the HTML then
1140
+ picks up the stream. Do not refetch in `onSettled` / `hydrate` to "load for the
1141
+ client", and do not delay `renderToStream` to preserve visual order — `<Reveal>`
1142
+ controls when content *appears*, while HTML still ships as soon as it is ready.
1143
+ With the router's single-flight integration, a mutation response can seed the
1144
+ destination route's preloads; that is the refresh, not a follow-up `fetch`.
1145
+ When the app has a server bundle (`ssr` or server functions), production is
1146
+ `handleRequest(request)` from the built `dist/server/server.js` (or that
1147
+ module's Fetchable default `fetch`) — not an import from `solid-js`. Cloudflare /
1148
+ Netlify / Nitro Vite plugins adopt that handler — do not write a Solid adapter,
1149
+ a custom Worker, or a Netlify Function. For Node, use the official template
1150
+ `server.js` (web `Request` in, stream the response); do not invent an
1151
+ Express/Solid bridge. Client-only `start: true` is still static `dist/client`
1152
+ — there is no handler to wrap. Request middleware is the Vite option
1153
+ `start: { middleware: "./src/middleware.ts" }` (web `Request` + `next`), not
1154
+ Express `app.use`. Server functions that return components
1155
+ (`serverFunctions: { components: true }`) are experimental preview — do not use
1156
+ them unless the project already has that flag on.
1157
+
721
1158
  Unscripted forms: `<form method="post" action={createTodo.url}>` (the reference
722
1159
  `.url`), not a client `preventDefault` + `fetch`, and not a hand-built
723
1160
  `/_server/` URL. GET forms (`method="get" action={search.url}`) are only for
@@ -755,15 +1192,16 @@ how to reuse. Prefer the form on the right.
755
1192
  | `const rest = { ...props }` | `const rest = omit(props, "label")` |
756
1193
  | `const user = createMemo(async () => { await fetch(...); return id(); })` | read `id()` **before** `await` |
757
1194
  | `action(async function* () { await save(); setX(v); })` | `yield save()` or `await save(); yield; setX(v)` |
1195
+ | `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) => ...` |
758
1196
  | `todos()` on a store | `todos.length` / `todo.title` (property reads) |
759
- | `setTodos((t) => t.filter((x) => x.ok))` | mutate the draft, or `reconcile` |
1197
+ | `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 |
760
1198
  | `createEffect(() => user, (u) => log(u.name))` | `createEffect(() => user.name, (name) => log(name))` |
761
1199
  | `<Loading fallback={<PageSkeleton />}>{/* header + data */}</Loading>` | wrap only the data slot; chrome stays outside |
762
1200
  | `<Loading on={id} fallback={...}>` (the accessor) | `on={id()}` — a value, so identity changes can show fallback |
763
1201
  | `setCount(count() + 1)` when writes can batch | `setCount((c) => c + 1)` |
764
1202
  | `setHandler(fn)` to store a function | `setHandler(() => fn)` (otherwise `fn` is an updater) |
765
1203
  | `{user() ? <P user={user()!} /> : <SignIn />}` | `<Show when={user()}>{(u) => <P user={u()} />}</Show>` |
766
- | `<Child user={user()} />` for an async memo | `<Child user={user} />` and read `props.user()` under the child's `<Loading>` |
1204
+ | `const u = user(); return <Child user={u} />` / `user: Promise<User>` / `user: Accessor<User>` as the default | `<Child user={user()} />` and `{props.user.name}` under `<Loading>` — types stay `User`. Pass the memo itself only when the child must `refresh()` that source. `isPending(() => props.user)` works on the value |
767
1205
  | `lazy(() => import("./p").then((m) => ({ default: m.About })))` | `lazy(() => import("./p"), { export: "About" })` |
768
1206
  | `dangerouslySetInnerHTML={{ __html }}` | `innerHTML={html()}` (sanitized); never with JSX children |
769
1207
  | `onClick={setCount}` | `onClick={() => setCount((c) => c + 1)}` |
@@ -774,6 +1212,7 @@ how to reuse. Prefer the form on the right.
774
1212
  | `<For each={rows.slice(from, from + n)}>` | `<Repeat from={from()} count={n}>` |
775
1213
  | `{count() && <Badge />}` | `<Show when={count()}>` — `0` must not render as text |
776
1214
  | `throw new Error("expired")` from a server function (prod) | `throw markSafeError(...)` or `throw respond(body, { status })` |
1215
+ | `const r = await api.x(); if (!r.ok) ...` result-object checks at every read | throw unusable responses; contain each region with `<Errored>` |
777
1216
  | `GET(async (id) => { "use server"; await db.delete(id) })` | mutations stay on POST; `GET()` is for idempotent reads |
778
1217
  | `live()` yields as an event log to append | each yield **replaces** the current answer; yield current state first |
779
1218
  | `<article innerHTML={html()}>…children…</article>` | `innerHTML` **or** children, not both |
@@ -787,6 +1226,7 @@ how to reuse. Prefer the form on the right.
787
1226
  | `renderToString(() => <App />)` for an async or lazy-route tree | `await renderToStream(() => <App />)` (one consumer: `pipe` / `pipeTo` / `readable`). String render emits `<Loading>` fallbacks |
788
1227
  | `clientOnly(() => import("./Map"))` for a rarely shown widget | same + `{ lazy: true }` so the import waits until first render (default starts at declaration) |
789
1228
  | `<NoHydration><Chart /></NoHydration>` to skip SSR of a widget | `clientOnly(() => import("./Chart"))` — `NoHydration`/`Hydration` split ownership, they do not choose visible content |
1229
+ | `clientOnly` split (or `isServer` branch) for one browser-only *value* | `createMemo(() => localStorage.getItem(k), { ssrSource: "client" })` — `"hybrid"` when server data mixes with client signals |
790
1230
  | `await flush()` / `flush()` to wait on a pending memo | `await resolve(() => value())` (or Testing Library async queries); `flush()` only drains staged *sync* writes |
791
1231
  | `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 |
792
1232
  | `createContext` + a root provider for a true app singleton | module-level signal/store (SSR: shared across requests). Context is subtree scoping |
@@ -802,6 +1242,8 @@ how to reuse. Prefer the form on the right.
802
1242
  | `const View = tab() ? A : B; return <View />` | `const View = dynamic(() => (tab() ? A : B))` — the ternary at setup freezes the choice |
803
1243
  | `<For each={resolved()}>` after `children()` | `{resolved.toArray()}` — resolved children are not a reactive data list |
804
1244
  | `<input onChange={...}>` for keystrokes | `onInput` — native `onChange` is blur/commit |
1245
+ | debounce as an effect copying one signal into another | debounce at the handler: `onInput={debounce((e) => setQuery(e.currentTarget.value), 150)}` |
1246
+ | `value={query()}` on a search box whose writes feed a request | `value={latest(query)}` so keystrokes show while the write is held (uncontrolled needs nothing) |
805
1247
  | `createOptimistic` for a local edit draft | writable derivation / plain store. Optimistic is for an in-flight mutation |
806
1248
  | `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`) |
807
1249
  | `setSubmitted(true)` + an effect that watches it | do the work in the handler or an `action` |
@@ -809,14 +1251,31 @@ how to reuse. Prefer the form on the right.
809
1251
  | `action={"/_server/" + id}` in app JSX | `action={createTodo.url}` or a router `action` |
810
1252
  | `<form method="get" action={update.url}>` for a mutation | GET forms only for idempotent search; mutations are POST |
811
1253
  | `window.location.href = ...` / `history.pushState` | `useNavigate()` or `<a href={Router.paths...}>` |
1254
+ | `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 |
1255
+ | `new URLSearchParams(location.search)` hand-rolled query parsing | `useSearchParams()` (setter merges, no scroll); typed via a route `search` schema + `useSearchParams(Router.paths.search)` |
1256
+ | `Router.paths.about()` / string-concatenated URLs | static nodes are properties (`Router.paths.about`); calls bind params, then search object and hash |
812
1257
  | `httpStatus(404)` in a click handler | call it bare in the component / error-fallback body (a scope declaration, not a mutation) |
813
1258
  | `try { user() } catch (e) { /* NotReadyError */ }` | `<Loading>` — app code should not catch `NotReadyError` |
814
1259
  | `{latest(() => results())}` as the visible list | settled read + `isPending()` for the indicator; `latest` is for previews |
1260
+ | `selectedId={latest(selectedId)}` as the default highlight | `selectedId={selectedId()}` so highlight and content stay consistent; `latest` only when the highlight should move first |
1261
+ | `isPending` on a root/global pending signal, or wrapping the whole shell | ask the question at the design site (dimmed list, disabled button, pane). It works on the source, a derived prop, or the write |
1262
+ | `<Loading>` glued to the `createMemo` (consume + block in one place) | fetch high, block low — the boundary wraps the read, not the creation |
1263
+ | `class={{ pending: isPending(id) }}` flashing on every navigation | same class + a short CSS `transition-delay` so only slow swaps show |
1264
+ | `<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}` |
1265
+ | `<Errored>` as a terminal ErrorBoundary; `reset={() => location.reload()}` | the boundary heals when the source succeeds again; `reset` retries collected *sources*, not a UI remount |
1266
+ | `<Errored>` around a list catching a toggle/save failure | catch expected mutation failures in the `action`; keep row errors in a map the projection folds in (survives overlay discard) |
1267
+ | `yield { ...tick, connected: true }` from `live()` | yield the value; wire status is `source.onstatus` (`"connected"` / `"reconnecting"` / `"closed"`) |
1268
+ | sibling `<Loading>` fallbacks stacking as popcorn | `<Reveal collapsed>` (sequential default) suppresses tail skeletons past the frontier |
1269
+ | in-memory cache around a GET server function | `return respond(value, { headers: { "cache-control": "..." } })` — metadata rides HTTP, the value rides the graph |
1270
+ | `story()?.title` / treating `story()` as `Story \| undefined` | `{story().title}` under `<Loading>` — if the computation is running, the value is there |
815
1271
  | `typeof window !== "undefined"` | `isServer` / `isDev` from `@solidjs/web` |
816
1272
  | `Object.assign({}, props)` / expecting `merge` to skip `undefined` like 1.x `mergeProps` | `omit` / `merge` — explicit `undefined` **overrides** (Object.assign) |
817
1273
  | `isPending(user())` / `latest(user())` | `isPending(user)` / `latest(user)` — pass the accessor (or `() => user()`). Calling it first evaluates the read before the helper runs |
818
1274
  | `isPending(...)` as the first-load spinner | `<Loading>` owns first flight; `isPending` is the *refetch* indicator after a settled answer exists |
1275
+ | a held write with no feedback anywhere | pair it with `isPending()` / `latest()` / an optimistic value / `affects()` — otherwise dev + attribution reports `[SILENT_HOLD]` |
819
1276
  | `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 |
1277
+ | `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 |
1278
+ | `submission.clear()` from an effect | on dismiss/resubmit only — from an effect, errors flash and vanish |
820
1279
  | `revalidate(liveSource)` | `live()` updates through the open stream; do not revalidate it |
821
1280
  | `refresh(user)` without `affects` when the reload should look pending | pair `affects(user)` with `refresh(user)` — a bare `refresh` re-asks quietly |
822
1281
  | `<Dynamic component={tab() ? A : B} />` | `const View = dynamic(() => (tab() ? A : B))` — `<Dynamic>` is a JSX convenience; app code should use `dynamic()` for stable identity |
@@ -837,6 +1296,16 @@ how to reuse. Prefer the form on the right.
837
1296
  | `createRenderEffect` / `createTrackedEffect` as the default effect | two-phase `createEffect(compute, apply)` |
838
1297
  | `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 |
839
1298
  | 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 |
1299
+ | 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 |
1300
+ | 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 |
1301
+ | 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 |
1302
+ | `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 |
1303
+ | router/server mutation, then `fetch` / `onSettled` / `hydrate` to refresh the page | `reload` / `revalidate` / single-flight (the mutation response can seed destination preloads). In-flight Promises serialize as Promises; `live()` continues from HTML |
1304
+ | delay `renderToStream` so HTML arrives in visual order | stream as soon as ready; `<Reveal collapsed>` controls when content *appears* |
1305
+ | subscribe/unsubscribe in the component around `live()` | `createMemo(() => stockPrice(props.symbol))` — hoist that memo to share one connection. `onstatus` is on the iterable the `live()` call returns, not on the memo. Until the first yield, `<Loading>`; later yields are updates |
1306
+ | a Solid adapter / custom Worker / Express bridge, or `entry-client.tsx` under `start: true` | With a server bundle: `handleRequest` from `dist/server/server.js` / Fetchable `fetch`; platform plugins adopt it. Node: template `server.js`. Client-only start: static `dist/client`. Middleware is `start: { middleware: "..." }`, not Express `app.use` |
1307
+ | `createMemo(async () => (await props.story).author)` | `createMemo(() => props.story.author)` — a derivation over an async value becomes async; no await, no Promise type |
1308
+ | return a component from `"use server"` / flip `serverFunctions.components` | experimental preview — do not enable unless the project already has that flag |
840
1309
 
841
1310
  ```tsx
842
1311
  // WRONG — post-await read never subscribes; production can hang pending
@@ -869,23 +1338,24 @@ function Field(props: { label: string; class?: string }) {
869
1338
  ```
870
1339
 
871
1340
  ```tsx
872
- // WRONG — user() throws not-ready at Page; child's <Loading> never sees it
1341
+ // WRONG — extracting at the parent is a real read; child's <Loading> never sees it
873
1342
  function Page() {
874
1343
  const user = createMemo(async () => (await fetch('/api/me')).json());
875
- return <Profile user={user()} />;
1344
+ const current = user();
1345
+ return <Profile user={current} />;
876
1346
  }
877
1347
 
878
- // CORRECT — pass the accessor; read it under the boundary that owns the fallback
879
- function Profile(props: { user: Accessor<User> }) {
1348
+ // CORRECT — JSX props are lazy (passing isn't reading); types stay User
1349
+ function Profile(props: { user: User }) {
880
1350
  return (
881
1351
  <Loading fallback={<p>Loading…</p>}>
882
- <h1>{props.user().name}</h1>
1352
+ <h1>{props.user.name}</h1>
883
1353
  </Loading>
884
1354
  );
885
1355
  }
886
1356
  function Page() {
887
1357
  const user = createMemo(async () => (await fetch('/api/me')).json());
888
- return <Profile user={user} />;
1358
+ return <Profile user={user()} />;
889
1359
  }
890
1360
  ```
891
1361
 
@@ -960,21 +1430,39 @@ always-applied rules installed alongside this skill.
960
1430
  is a writable derivation (`createSignal(() => { source(); return initial; })`), not
961
1431
  an effect calling the setter.
962
1432
  - [ ] Event handlers wrap setters (`onClick={() => setX(...)}`), use `currentTarget`,
963
- and `onInput` for keystrokes. `innerHTML` is sanitized and not mixed with children.
964
- - [ ] Async memos are passed as accessors (`user={user}`), not `user={user()}`, so
965
- `<Loading>` around the *read* can catch not-ready.
1433
+ and `onInput` for keystrokes. Debounce at the handler, never as an effect copying
1434
+ signals. `innerHTML` is sanitized and not mixed with children.
1435
+ - [ ] Async memos are passed as values (`user={user()}`); JSX props are lazy.
1436
+ Types stay `User` — not `Promise<User>` / `Accessor<User>`. `<Loading>` wraps
1437
+ the *read*, not the memo, not chrome. Extracting `const u = user()` at the
1438
+ parent is the read that throws. Nested child fetches run in parallel
1439
+ (components do not suspend); a real waterfall is `fetchB(a().id)`.
1440
+ `isPending` is per-expression (`isPending(() => props.story)` works);
1441
+ `latest(selectedId)` is opt-in, not the default highlight. Inputs feeding a request
1442
+ bind `latest(query)`; failures throw to `<Errored>`, never `{ success: false }`
1443
+ result objects checked at every read.
966
1444
  - [ ] Lists via `<For>` (server/refetched rows keyed by stable id), conditionals via
967
1445
  ternary/`<Show>`; no `{list().map(...)}` in JSX; no `key` props; no `value()!`.
968
1446
  - [ ] Effects are two-phase and only at imperative boundaries; apply does not read stores.
969
1447
  - [ ] Async reads sit under `<Loading>` (the data slot, not chrome); errors under `<Errored>`.
970
1448
  Reactive inputs of an async memo are read before the first `await`. No hand-rolled
971
1449
  `loading`/`error` signals or `=== undefined` readiness branches. First load is
972
- `<Loading>`; refetch indicator is `isPending(user)` (the accessor, not `user()`).
973
- Core `refresh(source)`, router `revalidate(key)`, and `return reload(...)` are
974
- different APIs — do not mix them.
1450
+ `<Loading>`; refetch indicator is `isPending(user)` (the accessor, not `user()`)
1451
+ or `isPending(selectedId)` when the write is the question. `<Errored>` heals
1452
+ when the source succeeds; `reset` retries sources, not a UI remount. Per-row
1453
+ mutation failures stay in the action/projection, not in `<Errored>` around
1454
+ the list. Core `refresh(source)`, router `revalidate(key)`, and `return reload(...)` are
1455
+ different APIs — do not mix them. Client → server is additive (same
1456
+ setters + `action` + server functions): do not rewrite App with
1457
+ loading/error branches, disable optimistic rows until ack, or refetch in
1458
+ `hydrate` / `onSettled`. HTTP does not enforce TypeScript — validate
1459
+ inside `"use server"`; do not invent tRPC. `live()` owns the client connection.
1460
+ A server bundle uses `handleRequest` from `dist/server/server.js`; client-only start
1461
+ is static `dist/client`. Do not delay `renderToStream` for visual order
1462
+ (`<Reveal>` does).
975
1463
  - [ ] External collections reconcile into stores (function-form `createStore` /
976
- `reconcile`), never wholesale draft assignment or `setTodos((t) => t.filter(...))`
977
- as the identity-preserving update.
1464
+ `reconcile`), never wholesale draft assignment of a fresh tree
1465
+ (`setTodos((t) => t.filter(...))` for removal is fine — survivors keep identity).
978
1466
  - [ ] Rest props via `omit`, not `{ ...props }`. Conditional `class` via objects, not string concat.
979
1467
  - [ ] Input filters write back to the DOM on reject (inputs do not rewind).
980
1468
  - [ ] No Solid 1.x imports or APIs (`solid-js/store`, `createResource`, `onMount`, `Suspense`, ...).
@@ -982,12 +1470,25 @@ always-applied rules installed alongside this skill.
982
1470
  `solid-js`; DOM `JSX` types come from `@solidjs/web`.
983
1471
  - [ ] Inspect children with `children()`; code-split with `lazy` + `<Loading>`; reactive
984
1472
  component choice with `dynamic()`. No `React.lazy`, no effects inside ref callbacks.
985
- - [ ] Browser-only code uses `isServer` / `clientOnly`, not `typeof window`.
1473
+ - [ ] Browser-only code uses `isServer` / `clientOnly` (components) / `ssrSource`
1474
+ (values), not `typeof window`.
986
1475
  Component teardown is `onSettled` (return cleanup), not `onCleanup`.
1476
+ - [ ] Dev console shows no diagnostics: reads in tracking scopes (no
1477
+ `[STRICT_READ_UNTRACKED]`), held writes paired with `isPending` / `latest` /
1478
+ optimistic values / `affects()` (no `[SILENT_HOLD]`). Attribution-only costs
1479
+ (`[IMMUTABLE_UPDATE_IN_STORE]`, `[UNSTABLE_LIST_IDENTITY]`, `[EFFECT_*]`)
1480
+ checked via `DEV.attribution` when touching stores, lists, or effects. Name
1481
+ interrogated scopes (`{ name: "total" }`).
987
1482
  - [ ] If the project has a router: `createRouter({ routes })`, not JSX `<Route>` / `<A>`.
988
1483
  One instance, no nested `<Router>`. Navigate with `useNavigate` / `<a href>`, not
989
1484
  `window.location`. Router `action`/`query` come from `@solidjs/router` (POST forms + cache), not
990
- core `action`/`refresh`. Forms: `<form action={save} method="post">`.
1485
+ core `action`/`refresh`. Forms: `<form action={save} method="post">`. Core `action`
1486
+ bodies are generators (`function*` / `async function*`) suspending on `yield`,
1487
+ never plain `async` functions. Link state is automatic attributes + CSS
1488
+ (`aria-current` / `data-active` / `data-pending`; `useLinkState` / `useIsRouting`
1489
+ in JSX). Search params via `useSearchParams`. Forms validate on the server
1490
+ (`throw respond({ issues }, { status: 400 })`) with inline errors from
1491
+ `useSubmissions`.
991
1492
  - [ ] `render(() => <App />, root)` — a function, not `render(<App />)`. `hydrate` when
992
1493
  HTML already exists. Stream async/lazy trees (`await renderToStream(...)`) with
993
1494
  exactly one consumer (`pipe` / `pipeTo` / `readable`). Snapshot stores with