github solidjs/solid @solidjs/signals@2.0.0-rc.7

Patch Changes

  • #3310: Preserve queued effects when pending actions merge into another transaction, preventing stale DOM output after signal values commit. Thanks @DerpyCrabs!

  • 215de3b: Align store overloads across the signals, client, and server entry points. Plain stores share StoreOptions, projection forms share ProjectionOptions, plain optimistic stores expose their existing options argument, and derived optimistic stores are typed as refreshable.

  • 6c8c956: Attribution: feedback() gains the fact tables that have no verdict of their own. flights counts, per async source, flights started, landed, and abandoned (superseded by a newer flight before landing — the re-ask-on-every-keystroke signature) with landed wall time. fallbacks measures, per loading boundary (named by owner path), how many times and for how long its fallback was shown and how many shows were sub-150ms flashes — the other end of the SILENT_HOLD spectrum. sources rows gain late/lateMs: acknowledged holds that still ran past holds.infoMs, where the affordance is not the whole answer. New AttributionHooks.boundaryFallback hook point at the boundary's source-set transitions. @solidjs/diagnostics exports the FlightStats and FallbackStats types; artifacts and the bridge carry the new tables through the existing feedback field.

  • 1a1e2f2: Attribution: feedback() — what the user waited on, as ranked tables.

    DEV.attribution.feedback() is a pure fold over the records the engine already keeps — holds() and the interaction on each re-run — with no measurement or hook sites of its own, the way costs() folds re-runs into scope and write tables. sources ranks each set of async sources that held writes by the silent time spent behind them, with holds/heldMs/worstMs, silent/silentMs, acknowledgedBy (which affordance answered and in how many holds — a source acknowledged on one screen and silent on another reads as exactly that), latestOnly (answered only by a latest() shadow), the interactions that were held, the distinct writes, and actions. interactions ranks user events (type + target; repeated dispatches fold together) by total cost, pairing the synchronous re-run work one dispatch caused (runs, selfMs, worstDispatchMs — the long-flush hazard) with the time its writes spent held (holds, heldMs, silentMs, worstHoldMs — the silent-hold hazard): the two INP failure modes as columns of one row. Every hold counts at any duration; SILENT_HOLD remains the thresholded verdict over the same records. New exported types FeedbackSource and FeedbackInteraction; the reactivity-diagnostics skill gains a "where to start" entry, and solid-js's console footer names the surface.

  • 7c14e23: Attribution: write provenance — who performed a change.

    Every root ChangeRecord now carries origin: the imperative frame that made the write. interaction (a user event — type, described target such as button#next "Next →", and dispatch time), effect (the callback's name), action (the generator's name), async (the landing's node), or external (timers, sockets, promise callbacks — including writes after an await rather than a yield inside an action, the documented transaction escape). Frames nested under an interaction carry it: an action a click started (every step, including post-yield resumptions), an effect whose run a click's write caused, an async flight a click's write launched. Why-chains print the origin after the write; RerunEvent.interaction and HoldEvent.interaction expose the interaction a run or hold traces back to, and SILENT_HOLD now opens with what the user did and measures the wait from the event, not from the first parked flush.

    @solidjs/web declares the interaction around its two dispatch sites — delegated events (onClick, onInput, onKeyDown, pointer events: every INP-relevant type) and runtime-attached direct handlers (spreads, non-literal handler expressions) — via the new DEV.attribution.withInteraction(ref, fn), which custom renderers and test harnesses can call themselves. New core dev hooks effectRunStart/effectRunEnd (replacing effectRun) and actionStepStart/actionStepEnd; all sites fold out of prod, verified byte-identical against the size scenarios.

  • ef2b02c: Internal cleanup with no behavior change: inline four single-use helpers (hasContext/isUndefined, markCovered, shallowWithSymbols), delete two dead ones (isNextProxy, ownEnumerableKeysPlain), and collapse spread()'s nullish-source handling into one accessor closure. A few dozen bytes off the app scenarios.

  • c6c415b: Mark createTrackedEffect as @deprecated. It is retained to ease 1.x migration, but it should not appear in new code: use createEffect(compute, effect) for side effects that follow reactive state (it separates tracking from the side effect, knows its dependencies before it runs, and participates in async and transitions) and onSettled for one-time DOM work after render. onSettled is unaffected (it uses the internal tracked-effect node directly).

  • 6c8c956: Diagnostics console addressability: compiled JSX binding effects (attribute, class, style, property, spread, insert) are tagged in dev with the element they write, and a console diagnostic about such an effect prints that element as a second argument — hover highlights it on the page, click jumps to it in the Elements panel. Why-chains (DEV.attribution.enable() logging) print as collapsed console groups, one headline per run with the causes inside. The once-per-code footer now pairs the installed skill path with the file's stable GitHub URL, anchored to the code's section.

  • d5aba4b: WIDE_WRITE and HOT_SCOPE_FANOUT diagnostics, and the reactivity-diagnostics and agent-loops skills, now prescribe a projection (createProjection, or a createStore(fn) keyed by id) as the fan-out repair. They previously named an API that is not part of 2.0 (#3304).

  • 3ae0ca0: Diagnostics locate themselves and report once.

    • Every DiagnosticEvent now carries ownerPath — the root-first chain of named owners enclosing the subject (["<App>", "<TodoRow>", "effect"]). Component roots are labeled <Name> by solid-js's dev component wrapper, so the path reads as the component tree down to the scope; owned-scope write errors in a component body now say (in <TodoRow>).
    • Console reports are a single entry per finding: message, an in <App> › <TodoRow> › effect line, and the once-per-code repair footer as trailing lines — the footer no longer lands as a separate, duplicate-looking [CODE] line. Advisory (info) events emit no footer at all.
    • ASYNC_OUTSIDE_LOADING_BOUNDARY fires once per render() instead of once per pending render effect (N async siblings at mount produced N copies).
    • New dev-only helpers on the signals core: reportDiagnostic(entry) (the console face) and ownerPath(subject); emitDiagnostic takes an optional subject (defaulting to the ambient reactive context).
  • ae46c92: Comment-only: update doc paths in source comments after relocating the signals internals docs (INTERNALS-*, SPEC-ASYNC-SEMANTICS, rules-mining) from the package root into packages/signals/docs/. No behavior change.

  • 6c8c956: Attribution: EFFECT_RELAY_TEAR diagnostic — derived state kept in sync by an effect (createEffect(() => f(a()), v => setS(v))). "Should have been a memo" is a claim about intent the runtime cannot see; what it can see is the harm: every scope that reads both a and S runs twice for one write of a — once in the flush where a changed (against the stale S), once after the effect's write lands — and the first frame was inconsistent. The engine proves that from the cause chain (a re-run whose root writes all came from effects, one of whose runs shares a root write with the victim's previous run) and reports it once per relay, with intent heuristics as message modifiers rather than gates: copy (the written value is the effect's compute output — by contract a pure function of its tracked reads, so derivable; warns immediately, and on its own after two runs even with no double-running reader, since everything reading the copy paints a flush behind the source), passthrough (the compute output is one of the effect's sources — the prop-to-state port: read the source directly), and soleWriter (nothing else writes the signal). A tear whose write is none of these is info (a DOM-measurement effect tears legitimately — the cost of measuring) until the same relay has torn three times. The reactivity-diagnostics skill documents the code and repairs.

  • 1a1e2f2: Attribution: EFFECT_WRITES_OWN_SOURCE diagnostic. An effect whose callback writes a value its own inputs depend on converges (the second run finds nothing to change) rather than looping, so the flush guard never fires — yet the flush settled in two passes and the screen rendered the pre-write value in between. The engine now walks each effect re-run's cause chain (root writes, through any depth of memos) and, when a root write's effect origin resolves to the effect that is re-running, reports the cycle once: warn for a single effect (the written value is a function of what the effect reads — make it a memo, or normalize where the source is written), info for a cycle relayed across several effects (each effect-origin write is joined to the run that made it, so the walk continues hop by hop). Effect-origin ChangeOrigin frames gain run, the RerunEvent.run whose effect phase performed the write. The reactivity-diagnostics skill documents the code and repair.

  • f98bd77: Hold conditional reveals that first observe async work started in an earlier flush. The revealing signal now stays pending until the details can commit with it, including when the reveal creates a new child reader. Preserve fresh/reset loading-boundary fallbacks and avoid opening a second transition for readers already waiting in one.

  • fc7e626: Merge clone-path folds onto a container privatized mid-batch (#3271). Family and array drafts fold by swapping their pending backing in and re-slotting the parent with a CAS against the pre-batch old. When a descendant of the same node was written earlier in the draft, the descendant's fold path-copies THROUGH the ancestor first — privatizeCommitted clones the ancestor's committed backing and re-points the parent slot at the clone — so the ancestor's own fold swapped in a stale ensurePB-time clone, failed the parent CAS, and its writes were silently discarded (writable projections; plain object stores fold through the overlay path and were immune). Such folds now merge the batch's written keys onto the privatized container in place — the trap's written-keys bound is authoritative, with a value-diff fallback when an array length write poisoned it — composing both folds instead of losing one.

  • d50e855: rc.6 P1 store sweep — three fold-machinery gaps reported by @brenelz, all predating the #3271 fix:

    • #3282 — an array move (reverse/unshift/splice) plus an edit of a moved row corrupted sibling rows: the row target's parent-key is stamped at wrap time and never followed the move, so the fold's parent-slot re-point wrote the edited row's clone over whichever sibling now occupied the old index ([1,2] became [1,1]). Fold-time slot writes (privatization stitch, drainFolds path-copy, and the eager-fold twin) now resolve the slot by raw identity when the stamped key is stale — arrays only, fold-time only, no read-path cost.
    • #3283deep() silently unsubscribed from every untouched child after a parent-field edit: the walk bypasses the proxy traps, and a bare Reflect.ownKeys on a plain-object overlay pending backing (own keys = this batch's writes) hid inherited committed keys from the mid-flush re-walk, dropping those records from the effect's refreshed dependency set. The walk now merges committed keys minus deletes, mirroring the ownKeys trap's #3044 overlay merge.
    • #3284 — in derived stores, a descendant write disconnected ancestor observers and broke proxy identity: privatizeCommitted registered its clone only in the global lookup, but family targets resolve children through fam.map, so the next parent read wrapped a fresh target and orphaned the original's nodes. The clone now registers in the target's own map.
  • 8f9f369: Suspend uninitialized async values across optimistic lanes so latest()-conditioned branches wait for their first value instead of rendering undefined.

  • c531e2a: Fix refresh() of an optimistic value throwing GlobalQueue._notifyAuthoritativeObservers is not a function and halting the graph in apps that never call until() (#3303). The refresh waiter reads authoritatively — the override is not the answer it waits for — which marks the node as authoritatively observed; when the re-ask then landed equal to the override, the wakeup went through a late-bound hook only until() installed. refresh() now installs it too.

  • aed21ac: Fix a same-batch store reset leaving subscribers on the cancelled value (#3296). A draft write (setStore(s => { s.count = 1 })) notifies its node at setter exit; an adoption in the same batch (setStore(reconcile(...)) or a returned replacement) discards the draft and diffs the incoming object against the committed backing, so a key the draft changed and the adoption restored never re-notified — untracked reads showed the reset while memos and effects committed the draft value. Adoptions now diff against the view the nodes were last told — the draft's pending backing when one preceded them — exactly as a second draft write would; the last write wins for every setter form.

  • b3c94be: Fix createTrackedEffect missing a signal written during a render-effect callback (#3291). Tracked effects read with committed visibility, but their wake was pushed straight into the user queue at notify time, so a write staged during a flush's render phase re-ran the effect in the same pass — before the value committed — and nothing re-notified it afterwards. Wakes (and the first run) now ride the heap like every other subscriber, so the run always lands after the commit; the tracked-effect special case in the scheduler is removed.

  • 0653673: Route errors thrown while applying asynchronous computed setters through the
    node's error state. This prevents user callbacks invoked during asynchronous
    projection reconciliation, such as key selectors, from escaping as unhandled
    promise rejections.

  • 6c8c956: Attribution: IMMUTABLE_UPDATE_IN_STORE diagnostic. A store setter that replaces a container with a fresh object or array whose leaves are mostly the same values — draft.user = { ...draft.user, name }, draft.items = [...draft.items, x], draft.items = draft.items.filter(…) — is the React habit the store does not need: it tracks leaves, so a fresh container makes every reader of the container's path re-run for the one leaf that moved. The store's write-channel notify now announces replaced containers to the attribution engine with a leaf census (identity on unwrapped values; object keys by key, array items by membership; containers over 64 leaves are skipped), and the engine warns once per store path when at least half the leaves carried over unchanged, naming the draft mutation that touches only the changed key or index and reconcile() for data arriving from outside. Genuinely new data (nothing carried over), draft mutation, and reconcile() do not report. New AttributionHooks.storeReplaced hook point.

  • 94fe5b4: Drop the lane source's redundant isPending companion refresh on derived pending/settle; the verdict never depended on it and the source's own paths keep it current.

  • 23477ae: Optimistic and latest() lanes now hold under the same rule as transactions: async derived from the lane holds the lane's reveal only when a render effect observes it pending and no Loading boundary catches it. An async memo nobody renders, or one inside a boundary that shows its fallback, no longer blocks the lane (#3289).

  • f4d3c87: Responsiveness thresholds and the LONG_HOLD diagnostic.

    • SILENT_HOLD defaults tighten to holds: { infoMs: 100, warnMs: 200 } (from 300/500): RAIL's "feels instant" ceiling and the INP "good" ceiling. The engine measures to the commit, not the paint, so every number is a floor on what the user saw; the console's thresholds now sit at the strict end of the band.
    • New LONG_HOLD (responsiveness kind): an acknowledged hold whose quiescent tail — from the last write to join it to the commit — reached longHolds.infoMs (default 500ms), warn from longHolds.warnMs (1000ms). Measured from the last join so a hold that keeps taking input is judged by each wait, not its lifetime. The repair is a fallback: a Loading boundary keyed with on (a revealed boundary without on keeps the old content — that is the hold), a fresh boundary, or making the data fast. A silent long hold stays one SILENT_HOLD with the same repair appended and data.long: true.
    • HoldEvent.tailMs added; holdMs now runs from the interaction dispatch or the first parked flush, whichever is earlier (a node rewritten mid-hold keeps only its latest record, so the flush clock keeps the first wait from being forgotten). ChangeRecord.at stamps root writes.
    • feedback().sources[].late/lateMs replaced by long/longMs: holds whose tail reached the long-hold threshold, acknowledged or not.
    • @solidjs/diagnostics artifact format version 3 (tailMs on holds, long/longMs on sources); hold evidence in assertion failures includes tailMs.
    • RerunEvent.phase value "transition" renamed to "held" ("plain" | "held" | "optimistic") — the dev surface uses one word for the state.
  • 067e3bc: Optimistic increments keep stacking after a sibling landing. With several
    optimistic-store actions in flight (optimistic votes++, server confirm,
    refresh(store)), the first vote's truth landing is staged into the
    transaction that still retains the second vote, and that vote's increment
    replays over it. A third click's draft then read the staged truth WITHOUT
    the replayed override: draft reads composed live overrides only while no
    pending backing existed, and votes++ reads before the first write triggers
    the view-reseed hand-off. It read base, wrote base + 1, and its override
    landed on the value already on screen — the click was invisible and the
    count stuck (or fell back) until truth caught up. Draft reads now compose
    overrides whenever the pending backing is not the draft's own view-seeded
    clone.

  • 8a65e5e: An optimistic store's first flight suspends into its Loading boundary again.
    The flight-owned transaction (#3146) was declared for the uninitialized
    first ask too, so every transition-riding consumer — render()'s scheduled
    root insert included — was held until the initial fetch landed: the page
    stayed blank (content outside the boundary included) and the boundary's
    fallback never showed, while createStore(fn, seed) and
    createOptimistic(fn, seed) in the same spot showed it. Nothing has
    committed on a first flight, so there is no truth to keep on screen and no
    optimistic state to protect: it now declares nothing, like the loading
    window (#2933). Refetch flights declare exactly as before, so bare
    optimistic writes during an in-flight refetch still ride the flight's
    transaction (#2951) and content stays put until the new truth lands.

  • f24e53d: refresh() after a held manual write stays a quiet re-ask. When an action
    wrote to a derived store and later called refresh(store), the lift of the
    manual-write mask (#3026) dropped the re-ask classification, so the refetch
    was treated as a brand-new question and pended every leaf — every sibling
    row lit up isPending, and a row-scoped affects() could not narrow it.
    The lift now keeps the classification: same-question motion stays silent,
    and only the written slot and any declared affects() mark read pending
    until the truth lands. Same-tick precedence (#2692) is unchanged.

  • fa568d3: Relocate #3277's uninitialized cross-lane suspension check from core read() into the optimistic module's laneSuspends. No behavior change — the check is only reachable under a lane, which implies the engine is installed — but the inline placement taxed every bundle including storeless floors (27-66 B across five size scenarios); in laneSuspends only bundles that retain the optimistic module pay.

  • d601119: Remove the experimental patch channel and patch-mode list driver (always opt-in, never default). Graph-native regions own value delivery and the unified-For design owns list structure, so the channel's parallel delivery machinery is retired: patch.ts/patch-driver.ts deleted, the compiler-contract exports (registerPatch/registerRowOps/registerSlotPatch/patchableRaw, patchDriver/rowProof/driveList) removed, the patchDriver compiler option dropped from both compilers, the insert $ll seam stripped, and the write-side channel struct dieted to the single written-keys bound (t.wk) the core fold/notify paths actually use. Store-family app bundles reclaim up to ~900 B brotli; every measured tier shrinks.

  • ac5159a: Preserve the supplied type in Store<T> instead of adding a shallow readonly mapping.

  • de1c8b5: Revert the complete-seed requirement on derived store forms (#3258). Derived createStore, createProjection, and derived createOptimisticStore accept Partial<T> seeds again, on maintainer review: requiring a full T forces callers to fabricate a throwaway complete object in the common async case — any object store reconciling on a non-id key needs the options slot, hence the seed slot — while the seed is never observable there (reads pend until the first resolution). The type-honesty concern it addressed is real only for sync draft-reading callbacks and is better served by the seedless-callback direction discussed in #3194. Since #3258 never shipped in a release, its pending changeset is dropped rather than superseded; the API is unchanged from 2.0.0-rc.6. The #3260 overload alignment (slot order, shallow in options, Refreshable derived returns) is unaffected.

  • c07a044: Add a development CJS build, dist/node.dev.cjs, selected by the development condition on the require branch of exports. Previously require always resolved to the production dist/node.cjs (__DEV__ false), so a CJS host that resolved solid-js's dev server artifact would get DEV === undefined from its @solidjs/signals dependency — a dev server runtime whose diagnostics channel was silently absent. Dev CJS is the unmangled twin of dist/dev.js; the production CJS is unchanged.

  • 01e3a57: Attribution: transition holds and the SILENT_HOLD diagnostic.

    When a write lands on async work the runtime holds it until the data settles — correct, but from the user's side the click did nothing until then. The attribution engine now records every such hold that staged a root write (DEV.attribution.holds(): duration, parked flushes, the held writes with their values, the async blockers, and which affordances answered it), and emits SILENT_HOLD when the screen provably rendered no acknowledgment: no isPending()/latest() reader anywhere downstream of the held writes or their blockers, no optimistic value, no affects() mark, and no effect ran inside the parked flushes. The verdict is tiered by holds: { infoMs, warnMs } (default 300/500ms): advisory on the structured channel, then a console warn naming the write, the blocker, and the concrete repair — isPending(() => blocker()), latest(source), or createOptimistic for actions. Holds with no root write (initial loads, bare refresh()) are never judged.

    New dev hook points on the core (effectRun, holdStart/holdEnd, transitionSettled, transitionMerged) sit outside every try and fold out of prod — verified byte-identical against the size scenarios. DiagnosticKind gains "responsiveness"; solid-js's console footer teaches the attribution surface for it, and the reactivity-diagnostics skill documents the repair.

  • e346e61: Store leaf nodes ride slotSignal — one pre-shaped literal with _host/_key backrefs replacing the per-node options object, equals closure, unobserved closure, and the NodeExtension that held it; the unobserved sweep dispatches CONFIG_SLOT_NODE nodes to one shared hook. getNode self-time −23% on dbmon warm mounts.

  • 713a910: Internal: the store fold's descriptor-preserving property copy is one copyOwn helper instead of three inline repeats. No behavior change.

  • e346e61: Store first-read diet: the get trap's accessor probe verdict threads through to node creation (one descriptor scan per first read, not two), the trap's duplicate node-map lookup is hoisted, and the first tracked read populates the wrap cache so the second read skips wrapNext. dbmon mount min −4%, get-trap self-time −19%.

  • 0255729: Stop the store proxy's dev strict-read check from firing on the engine's
    thenable probe. Resolving a promise with a store proxy — refresh(store)'s
    waiter delivers the store, Promise.resolve(store), return store from an
    async function — makes the engine read store.then synchronously in the
    caller's scope. When that scope carries a strict-read label (an effect
    callback, a component body) the read produced a spurious
    STRICT_READ_UNTRACKED warning, and against a refetching derived store it
    could escalate to the PENDING_ASYNC_UNTRACKED_READ throw, rejecting the
    promise being resolved. await refresh(list) inside an action logged the
    warning on every call. The then probe is not a read the user wrote and is
    now exempt from both.

  • 6c8c956: Attribution: UNSTABLE_LIST_IDENTITY diagnostic. When a mapArray/<For> update disposes and recreates most rows while the entering items are field-for-field equivalent to the ones they replaced (a re-fetch handed back fresh objects for the same records under identity keying, or a key function returned unstable keys), every row's DOM and state was thrown away and rebuilt for data that did not change. mapArray now hands the exited and entered items to the attribution engine after a churning commit (AttributionHooks.listChurn); the engine pairs them (by id/key/_id when present, else by position), samples shallow equivalence, and warns once per list naming the repair — key by a stable field or merge with reconcile(data, "id"), or, when a key function is already in use, return a stable field from it. mapArray nodes now carry the name option as their node name in dev so the list is named in the report.

Don't miss a new solid release

NewReleases is sending notifications on new releases.