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

Patch Changes

  • fd36d37: attribution.enable(opts) now returns the release of the hold it takes (idempotent, like subscribe), and options combine across holds by the most demanding request per key (the log prints while any holder wants it, a check runs while any holder wants it at the most sensitive threshold asked for, historyLimit is the largest), so a hold adds to what the engine does and never takes away what another asked for — a track enabled with log: false beside a console session leaves its log alone, in either order — instead of every call rebuilding the options from defaults. disable() is the full teardown whatever holds are outstanding (the console's reset), so re-enabling to reopen a window and calling disable() once cannot strand a hold. enablePerformanceTracks releases through the token. Dev builds create a component's console.createTask task only for components rendered while an attribution engine is installed — the stack capture per call roughly doubled dev mount for a session with nothing enabled; the tracks enabled at bootstrap still see every component's site.

  • fd36d37: Attribution engine: shared-consumer foundation

    • attribution.enable(opts) is a hold on the engine and returns its release (idempotent, like subscribe). The engine is one per page and shared by every consumer (a profiler track, an APM adapter, a diagnostics capture); it stays installed while any hold remains, and the last release uninstalls and clears everything. Options combine across holds by the most demanding request per key — the log prints while any holder wants it, a check runs while any holder wants it at the most sensitive threshold asked for, historyLimit is the largest — so a hold adds to what the engine does and never takes away what another asked for, whatever order the holds were taken in; releasing a hold withdraws its requests. A hold taken while already enabled opens a fresh window over the ring buffers and folds without disturbing live tracking state or other consumers' subscriptions. disable() is the full teardown whatever holds are outstanding (the console's and a test harness's reset) — a consumer sharing the page releases its own hold instead.
    • New AttributionOptions.checks (default true). false turns off all five cost checks — hotRuns, hotTime, wideDeps, unstableMemos, wideWrites — at once, so a records-only consumer pays for none of their bookkeeping. Hold/long-hold/waterfall tracking are unaffected.
    • isSilentHold and isLongHold are exported from @solidjs/signals/attribution (inert in prod), so consumers apply the engine's own hold verdicts instead of thresholds of their own.
    • InteractionEvent.at is now the browser event's own timeStamp when @solidjs/web dispatches the handler (guarded against epoch-clock stamps), so it equals PerformanceEventTiming.startTime for the same interaction — a direct join to INP. New InteractionEvent.inputDelayMs reports event creation → handler entry; handlerMs is now entry → return, and settledMs continues to measure from at.
  • fd36d37: Attribution engine: timeline records, and a flushStart hook

    Five new listener-gated records on attribution.subscribe: create (a computation's creation run — the mount flame), effect (an effect callback, timed and joined to its compute run), flush (one scheduler drain: runs, creations, whether it parked a transition, the interaction it served), flight (an async flight from origin to landing or abandonment, with the async node's owner path) and fallback (a loading boundary's fallback from show to hide). None is built, logged or folded unless something is subscribed to its type, so the console/agent readers pay nothing for records only a timeline wants. OBSERVE.subjectOf answers for the node-bearing ones.

    Core: a flushStart hook beside flushEnd (one drain, never nested), and effectRunStart now fires in observe builds like its effectRunEnd twin, so writes inside effect callbacks carry their effect origin in observe too, not only in dev. The observe core grows by 67 bytes minified; prod is byte-identical.

    The engine's effect-frame → node map is now filled by the first write inside a callback rather than by every callback (every reader resolves it through a write's origin, and most effect callbacks never write), which removes a WeakMap write per effect callback from the enabled engine's hot path.

    @solidjs/web/performance-tracks paints the new records: creation runs and effect callbacks on the Effects/Memos tracks, drains on the Propagation track (one wave per drain), flights and fallbacks on a new Async track.

  • 43fae6e: onCleanup callbacks on one owner now run in reverse registration order (unwind), restoring the 1.x #1562 semantics; in production, component bodies share the enclosing owner, so a parent that registers cleanup before rendering its children now tears down after them, matching dev (#3572).

  • fd36d37: transformSourceNames: the pass behind the Vite plugin's sourceNames.primitives option (a @solidjs/vite-plugin option, not a compiler transform() option — the compiler exposes it as this standalone function). Reactive primitives (createSignal, createMemo, createOptimistic, createStore, createOptimisticStore, createProjection) resolving to a solid-js / @solidjs/signals import are named after the identifier they are declared as — array pattern, binding, property key, or class field — prefixed with the enclosing non-component function (createCounter.value), never overriding an explicit name. Plain JavaScript in and out, so it runs on .ts/.js modules too.

    Stores honour name: createStore(value, { name: "todos" }) labels its property nodes todos.title in attribution output instead of store.title; derived and optimistic stores name their property nodes alongside the projection node.

  • c74365d: createRoot JSDoc now states that a root created inside an owner is disposed with it (detach with runWithOwner(null, …)); MIGRATION note added.

  • 7599885: Dev diagnostic UNSCOPED_HOLE_ALLOCATED_IDS: an unscoped hole that took hydration ids at a position the other side does not share (#3567 follow-up)

    The one hydration-key gap left after #3599 is a bare identifier bound to a function — const renderHead = () => props.header; <div>{renderHead}</div>. The compiler sees a value and scopes nothing; both runtimes unwrap the function, but not at the same point (the client's insert at the statement, the server's ssr() inside the walk after every scoped sibling reserved its slot), so the keys of the hole's content and of the holes after it permute. JSX.Element excludes functions in 2.0, so type-checked code cannot write this hole; by ruling it is not scoped (no production cost for a shape the types reject). Instead the dev builds detect the permutation and raise UNSCOPED_HOLE_ALLOCATED_IDS (warn, kind render, once per site) on both server render and client hydrate. The server reports structurally — the counter's next id when the hole was registered (data.registered) differs from the one it was evaluated at (data.before, data.after after it ran); the client, which always builds in place, reports when the content it built inside an unscoped function hole moved the counter and missed a server-rendered key. A function hole with nothing scoped after it lands on the same ids on both sides and stays silent (a boundary's zero-arity fallback={() => <F />} thunk built by the consuming hole is that shape). data.name is the function; the message names the fix: call the function at the hole ({renderHead()}) or pass the built value. Scoped holes, memo and component accessors, children(), <For> rows and the runtime's own children inserts never raise it.

    Plumbing: a dev-only sharedConfig.devPeekNextContextId() on both solid-js facades (the next child id of the current owner, read without consuming — on the server without materializing a pending hole slot), installed under the dev gate so the prod and observe artifacts of solid-js and @solidjs/web are byte-identical to before.

  • 6717d35: Diagnostic code consolidation. Codes are public API (the Sentry fingerprint roots); three renames.

    • WIDE_WRITE is folded into HUGE_FAN_OUT — one code, one threshold story. The core's always-on check warns at 2000 subscribers; the attribution engine's fanOut threshold (default 250, was wideWrites) warns earlier while it is enabled and reports through the same emitter, so data.count is the subscriber count and, from the engine, data.write says which write reached it ("write", "refresh", "async"). One WeakMap dedupes both reporters (re-warn after another 500). AttributionOptions.wideWrites → fanOut.
    • SERVER_FN_ERROR_SANITIZED and SSR_ERROR_SANITIZED are one code, SERVER_ERROR_SANITIZED — the same fact from two roads. data.source is "server-function" (severity error, from @solidjs/web/server-functions) or "ssr" (severity info, from the SSR <Errored>/rejection path); data.error is the original and data.wire the replacement on both.
    • ASYNC_WATERFALL is client-only again: the server's boundary-passes verdict is its own code, SSR_BOUNDARY_WATERFALL (kind ssr; info for two sequential waits, warn for three or more; data: { boundary, passes, sequentialMs }). data.side is gone with it.
  • f884589: A lazy memo (createMemo(fn, { lazy: true })) that went dormant and was later read again is owned by its owner again: the owner's dispose() tears it down and its siblings are no longer orphaned by the memo's next dormancy (#3554). Reading a dormant lazy memo after its owner has been disposed now returns the last committed value without reviving it.

  • 1d3ec5d: dynamic: a live server component's reconnect after its source switched arguments keeps the mounted instance at the standing address. The binding equals-gate read "the address that is not the delivered one" as incoming, but the memo holds its first (kept) resolution forever, so the reconnect's re-yield of the standing binding swung the frame back to the document's address and the reconnect's render landed where nothing was bound. The gate now reads its arguments as (prev, next) and delivers next's address when it is not the one showing.

    Signals: the lane landing in asyncWrite now calls a user equals comparator as (prev, next), the order every other commit path uses (it passed (next, prev)).

  • fd36d37: The fallback attribution record (and the feedback fold's shows/flashes) times the fallback's display, not the boundary's swap. A <Loading> swap is a staged write that lands with its frame (#3575: on follows the frame); the engine now holds the show under that transaction and stamps at at the end of the drain that rendered it, and a swap cleared before then — the content landed before the frame did, or the commit's own sweep cleared it ahead of any effect (the LOADING_ON_OUTSIDE_HOLD shape) — was never on screen and produces no record. AttributionHooks.boundaryFallback gains a fourth argument, the transaction the swap is staged in (null for a lane swap).

  • 873187b: Three responsiveness findings from facts feedback() already counted, emitted as they happen: ABANDONED_FLIGHTS (one source abandoned 3+ flights in a second — the request-per-keystroke signature), FALLBACK_FLASH (a Loading fallback shown under 150ms), STACKED_HOLDS (3+ interactions waiting in one hold when it committed). New AttributionOptions: abandonedFlights, fallbackFlashes, stackedHolds; FALLBACK_FLASH_MS is exported.

  • a124577: A Loading boundary whose fallback reads something not ready now reveals its content as soon as the content lands, instead of waiting for the fallback's own flight (#3540). The boundary's output pass derives from its _disabled switch, not its tree, so only the boundary sweep re-runs it — and that sweep ran at the commit, after the verdict the output's own pending read kept parking. Such a boundary is now judged before the verdict, under the transaction, so its output re-runs, reads the tree and drops the fallback's read ahead of the verdict. Boundaries whose fallback is ready are unaffected.

  • 3af4696: fix(web): an async dynamic() instance serializes its landing and the client adopts it (#3666)

    dynamic() no longer opts its memo out of hydration serialization. The per-instance value memo is an ordinary async memo: when the source introduced async (returned a thenable during SSR) its landing is serialized under the instance's id and the client memo adopts the record during hydration instead of re-running the source and waiting on it — the pending beat that committed the enclosing <Loading> to a fallback the server never rendered, then missed the SSR'd nodes. A server component lands as a flight reference (_$SC.r(id, address) now resolves to the call's binding, so a wrapped query() or async arrow around a server reference hydrates the same as a direct call), a tag name as its string, and a sync source lands nothing — unchanged.

    A source that resolves to a client component function cannot serialize and is now refused on the server at the point the memo would serialize it — new diagnostic DYNAMIC_ASYNC_COMPONENT (recorded on the observe channel; the memo rejects into the nearest <Errored> / onError in every tier). Move the async upstream (createAsync/createMemo read synchronously by the source) or use lazy().

    Wire shape: each async dynamic() instance now writes one hydration record, and every dynamic() instance allocates one more hydration owner id (keys under a dynamic() element shift by one slot).

  • 3613c3a: fix(signals): first async landing under an optimistic lane (#3648). A memo whose first result lands while it is a member of an optimistic lane publishes as a derived override with _value never committed. The settlePendingSource invariant no longer reports SETTLE_WALK_UNINITIALIZED_SOURCE for that landing (the displayed derived override is its truth) and, when it does fire, prints its message rather than only the repair-guide footer. An off-lane render reader of such a never-committed node now suspends (NotReadyError) instead of being served a fabricated undefined. An untrack(() => isPending(x)) inside a memo no longer enrolls the memo as a suppressed probe, so the router's query() flight is no longer duplicated by the lane wake.

  • ed60f05: A cleanup that disposes its own root (or throws) runs exactly once: the disposal list is detached before it runs (#3601).

  • 739404d: Fix an optimistic override superseded by its source's landing leaking the landed truth into a later action's lane frame (#3548). A lane pass composes the frame it applies ahead of the commit, so a superseded node now serves it the displayed override — A18 (c): the screen keeps the override until the owning transaction commits — and the reader is recorded for replay at that commit, since the superseded drop notifies nobody. Before, a fresh write's lane reaching a reader shared with the superseded node (two filtered keyed <For> lists over one optimistic store) re-derived one list from the truth while its neighbour still displayed the override, rendering the same row in two lanes.

  • ab254b0: Disposing an owner while an action is pending now runs the cleanups of a memo branch's previous frame. A recompute under a held transaction parks the previous pass's children and onCleanups as zombies until the commit retires them (#3404); the disposal walk never reached that parked frame, and the commit's drain then returned on the REACTIVE_DISPOSED flag the walk had set — so the cleanups never ran, and the zombies stayed subscribed and re-ran inside the torn-down tree once a later transaction drained the zombie heap. The death path (createRoot disposer, owner.dispose(), parent disposal, and a lazy memo losing its last subscriber) now drains the parked frame first; a rerun still leaves it rendering until commit. Fixes #3561.

  • ebc1b03: A zombie that recomputes stays a zombie (#3543). recompute and updateIfNecessary rewrote _flags wholesale and dropped REACTIVE_ZOMBIE, the flag that says a node sits on its owner's deferred-disposal chain. While any transaction was parked, the scheduler reruns zombies for mainline writes (#3463), so an owner that recreates a child each pass — a compiled <Show when={a() && b()}> condition — had its previous child rerun de-flagged; at the owner's commit disposeChildren then spliced that child out of the live chain instead of the pending one, orphaning the current child. The orphan stayed subscribed and recomputing forever: one leaked node per update, until HUGE_FAN_OUT. The flag now survives every per-pass wipe.

    A consequence pinned in lane-outside-view.test.ts: a zombie whose removal is staged by a transaction no longer holds that transaction's commit after it reruns — its say was always meant to be moot for the verdict that disposes it, and the extra hold was this bug.

  • 1d3ec5d: Live server components on the document face (Stage 8 B3)

    Server: a component a live server function answers with renders into the document under a live scope — every async source it reads takes its first value into the markup and is closed, so the document completes; nested server components inherit the scope. The scope is judged from the memo's owner, which also fixes an unbranded thenable-resolved stream in server-component scope serializing instead of pumping. New dev-only check SSR_UNDECLARED_LIVE_SOURCE: an undeclared async iterable still pumping five seconds into a document render is named (frame-stream renders are never judged).

    Client: the frames intercept is consulted synchronously by live() and its answer rides on the iterable (LIVE_LOCAL); a hydrating dynamic() adopts it as its value at t=0 — no request, no pending beat — and takes over at its hydration scope's release, re-yielding the adopted binding and connecting once at the live address. A boundary the page is still delivering is answered with a promise that lands at its reveal, so a dynamic(() => call()) over a streaming boundary waits for the document instead of fetching. dynamic's memo treats the same server-component instance (same component and address) as equal, so placeholder and per-address binding never remount.

  • 886850b: GRAPH_GROWTH (warn, perf): the live reactive graph — owners, computations, signals, edges — measured at each route's settle climbed on consecutive visits, and which measure climbed names the leak. The observe core registers top-level roots weakly; the engine walks the owner tree and the reactive graph it reaches at navigation settle (never a per-node counter), emits a graph attribution record (GraphEvent, GraphSize), and exports graphSize() from solid-js/attribution. graphGrowth: { visits, ratio } | false configures the check.

  • 3218b7a: A held derivation is not a proposal (#3612). A mainline write to a writable memo (createSignal(fn)) or function-form store (createStore(fn)) whose staging is a pass result held by another transaction no longer suppresses that transaction's re-derivation: the write still joins the transaction (A34), and the derivation re-runs under the hold with the written value as prev. Same-frame "manual write wins" (#2692) and last-write-wins for writes made inside the transaction are unchanged.

  • d2d7bd4: Held truth (#3164) is masked from lane passes only; every other deriving reader follows A29. Confirming truth staged into a transaction that retains optimism — a landing folded onto an optimistic store, or a carrier stolen by an awaited until() — is, to a memo or user effect, a staged value like any other: the pass derives from it and is held with the transaction, so it composes one staged world instead of staged truth beside committed neighbours. Fixes #3568 (an optimistic push/splice followed by a longer refetch handed <For> a HOLE: the pass read the landed length through the superseded override while the rows past it stayed masked to committed) and the owning-lane leak that #3589's owner exemption opened (the lane pass that owns the transaction painted the confirmed v1 beside saving=true). Untracked reads, stale readers of a foreign transaction, and lane passes — owning transaction or not — keep committed until the reveal; latest() and until()'s predicate tunnel through as before. Supersedes #3589.

  • 7742b28: Hydration: a write during snapshot capture to a plain signal that has no snapshot — one created before hydrate() began, such as module-level state minted from onSettled during the pass — now records the pre-write value as its snapshot and is held like any other write. Readers inside the hydrating scope keep serving the value the server rendered with and replay at scope release; readers outside see the write live. Previously such a write cascaded through the claim pass (whose DOM writes are skipped) and was lost, and a component rendered later in the same pass read a value the server never had. Computeds landing async values and projection leaves are excluded, matching the creation-time capture rules.

  • 40c65cd: A lane pass on an effect now releases an older frame's zombies at its tail (it direct-commits ahead of its transaction, like a contested mainline pass) and parks the frame it replaces as a lane frame, retired when the effect's run applies rather than at the action's commit. Fixes a keyed row moved by the second of two overlapping optimistic actions disappearing from the DOM — the first move's lane pass had stamped the insert effect for its zombies alone, and the second's refresh re-run disposed the lane's freshly built inner effect and held its replacement for a commit that never came (#3662). A held lane no longer runs the displayed frame's cleanups ahead of the reveal, nor republishes the retired frame under the lane's values after it.

  • 9c6c5cd: A rewrite of an optimistic node whose override no transaction owns no longer opens a transaction of its own. optimisticWrite passed whatever resolveTransition returned to initTransition, and for an orphan override that is null, which initTransition reads as "open a fresh transaction": the ambient batch was adopted into it, every plain write staged in the same tick was stamped as its pending node, and activeTransition stayed set for the rest of the caller's block. The latest() shadow is such a node: its first companion sync installs the orphan override and the second one hit this path, so after one latest(() => store.value) the second and every later setStore write in a tick was held by a transaction nobody started, and an untracked read of a projection served the committed value instead of the pending backing (0, 1, 0, 0, 0 where the same writes without the latest() read served 0, 1, 2, 3, 4). The changed-value branch now joins a transaction only when one is resolved, as the same-value branch already did.

  • 5f28e7d: Document the lean-posture contract for why()/subscriptions() on the records channel (why shares history("rerun")'s gate; subscriptions reads the graph and is unaffected); ratchet the engine-cost tripwire cap to 4.

  • c9e1954: Loading on follows the frame (#3540). When a dependency of on changes, the boundary still stops waiting on its current content immediately — the frame no longer waits for it — but its fallback swap now lands with the same frame as the change that caused it, instead of in the current frame beside content the change is still holding. Navigating product A → B inside an action (or by a write whose async is in flight) with the shell reading product(id) outside a <Loading on={id()}> that reads comments(id) goes [A] → [B + spinner] → [B + comments], not [A] → [A + spinner] → [B + spinner] → [B + comments]. If the comments land before the shell, no fallback is ever shown. Nothing else holding the frame, the fallback and the committed change land together in the same pass, as before.

    Read latest() in on (or any display-ahead state: isPending(), an optimistic signal) to keep the previous behavior — the fallback shows now, beside the still-held frame.

    If the same data the boundary is waiting on is also read outside it, the frame waits on that read and no fallback appears; DEV warns LOADING_ON_OUTSIDE_HOLD with the fix (move the outside read under the boundary). A frame held by the write's action or by other data past the content's landing shows no fallback either — a race, not a warning.

    Errored no longer accepts on (nor createErrorBoundary an on option). It was added in #3556 and never released in a stable — rc-only. Retry through the reset the fallback receives (fallback={(err, reset) => ...}), or re-mount the boundary on the dependency (<Show keyed when={id()}>).

  • 55779c0: Loading's on prop is a dependency list, not a key (#3540). The expression is tracked and its value is never compared: a write to anything it reads — plain, optimistic, or a source going pending — re-arms the boundary. A re-armed boundary that has something pending under it shows its fallback again; one with nothing pending does nothing (no fallback flash). latest() inside on is redundant.

    The re-arm lands in the current frame. A write that makes content pending is held by the readers still showing the old content, and its batch commits when the data lands — but the boundary's swap to its fallback is not part of that batch: it is applied at the flush's finalize, mainline, past any transaction park, so the fallback shows now beside whatever the write is still holding elsewhere on the page. Previously the swap was staged into the pending write's transaction and landed with its commit, by which point the data had arrived and the fallback never showed whenever any other reader of the same data existed (#3524, #3529). The children are not re-created; they stay alive behind the fallback.

    Errored accepts the same on: while it shows its error fallback, a change to a dependency clears the caught error and retries the children (reset keys). createErrorBoundary takes { on } as its third argument.

    Boundaries are exempt from A29 born-held: a Loading mounted while a transaction holds what it reads shows its fallback now (and reveals the staged content at the commit) instead of being born held with the transaction. Born held stays right for a plain memo or effect — published, its value would tear the frame — but a boundary that has not revealed is the exception by definition: its job is to catch what is not ready under it rather than let it hold. This also closes the static-vs-function-child <Show keyed> inconsistency from the issue.

  • 84562fc: LOADING_ON_OUTSIDE_HOLD now recommends the structural fix (one hold owns the data, or isPending() for the wait) and mentions latest() in on only as a capability. Docs and JSDoc for Loading on updated to match.

  • 28fcc9b: The DEV LOADING_ON_OUTSIDE_HOLD diagnostic now reports only the deterministic shape: on re-armed a Loading boundary while the very async source it is waiting on is also read by a live reader outside it, so the frame is held on that source and the fallback can never be seen (data.source names it; the fix is to move the outside read under the boundary). The after-the-fact report — the frame held by the write's action or by other pending data past the content's landing, so the staged fallback was cleared before display — is removed: that is a race the developer does not control, a fallback that loses it is a legitimate outcome, and the engine cannot tell an action that awaited exactly this data from one that awaited something slower.

  • 235173e: Prune the observability surface: OBSERVE.attribution.install, the AttributionHooks type, DEV.setConsoleFooter, the TraceSlot and OriginRef aliases, PerformanceTracksOptions.group, and the untested solid-js/refresh runtime modes (esm, webpack5, rspack-esm) are gone — @solidjs/compiler's transformRefresh({ bundler }) accepts only "vite" | "standard" to match. ownerPath() is now OBSERVE.ownerPath(subject) and diagnosticGuideUrl() is DEV.guideUrl(code); why() also accepts a scope name. Engine record types (RerunEvent, HoldEvent, …) live on solid-js/attribution only, and InteractionRef/NavigationRef on the main entries only. @solidjs/diagnostics types its record tables off the runtimes' own catalogue (RecordEvent<K>), adds the recovery table, and bumps the artifact formatVersion to 8.

  • f2bd662: OBSERVE.subjectOf JSDoc names the subscriptions() export of solid-js/attribution rather than the removed attribution.subscriptions() method

  • 756b1b3: omit()'s no-Proxy copy path re-homes accessors with the source as receiver instead of forwarding the descriptor, matching merge()'s copy path. A prop's getter is defined only for a read through its own object — the compiler's server-side props keep their state on the instance — so a copy that must stay live defines its own getter that reads through the source.

  • 384a631: One records channel: the attribution engine's records (rerun, create, effect, flush, flight, fallback, interaction, hold, navigation, graph) are RecordTypes entries delivered on OBSERVE.records.subscribe(type, (event, live) => …), with the live node beside each record. Removed attribution.subscribe (both overloads), OBSERVE.subjectOf, the AttributionRecords/AttributionRecordType types, and the isSilentHold/isLongHold helpers — HoldEvent now carries silent and long, computed at settle. attribution.history(), waterfalls(), holds(), navigations() and interactions() collapse into attribution.history(type). DiagnosticListener receives the subject as its second argument. The channel allocates nothing per emit (copy-on-write listener lists), and a RerunEvent is built only while a listener, a fold or the log wants it. Record listeners belong to the channel and are no longer dropped by attribution.disable().

  • 658eecd: Fix a chained optimistic store dropping a write back to the base's previous value (#3672)

    • createOptimisticStore(base) over a createStore serves the base's live value; its own nodes are links whose _value is never served and never updated when the base commits. The engine's no-op check for an optimistic write compared against that stale _value, so once a first action had been confirmed in the base, a second action writing the key back to its earlier value (position from 1 back to 0), re-adding a key the base had deleted, or popping a row the base had appended emitted no override while the action's other writes showed. The optimistic setter now syncs a chained node without an active override to the visible committed value before the engine write.
    • The revert consulted the same stale _value: the settle's notify compare (override vs _value) decided whether readers learn the guess lifted. When the base committed while an override was active and the action then wrote the key back to its pre-write value, the compare matched and notified nobody — a createMemo over the view stayed at the guess while the base (and untracked reads) showed the truth. The optimistic module now refreshes every armed chained node to the base's live value right before the revert, so the compare is exact: readers re-derive when truth differs from the guess and stay put when a confirmed guess settles.
  • 1a7d14f: Fix optimistic-store drafts losing the row a previous setter added in the same action (#3665)

    • rc.9 regression: the get trap's draft arm for an absent own key was gated on the reader rule (visibleOverride), so a second setter in the same synchronous action body read undefined where the first setter had pushed a row — length and in saw it, the value read did not, and a findIndex lookup missed. The draft is the writer's channel and composes on the tick's own unflushed writes (hasActiveOverride), as the has trap, visibleKeys and optimisticView already did.
    • The descriptor trap gains the same draft arm: ownKeys listed an added key while getOwnPropertyDescriptor reported it absent, so every enumerator — Object.keys, spread, Object.entries, JSON.stringify — dropped the row inside a later setter (pre-dates rc.9). deep()/snapshot() inside a setter compose on the draft's view the same way.
  • 5351a3e: OPTIMISTIC_REVERTED (info, responsiveness): an optimistic value the screen showed was replaced by a different one — reverted at settle, or superseded by the truth — with the source and both values. AttributionHooks.optimisticReverted is the seam; optimisticReverts: false disables it. The runtime's own optimistic nodes (isPending/latest companions, derived overrides) are never judged.

  • cb2fa7b: An action's transaction no longer parks forever on an optimistic store whose
    source is refetching under another transaction. When the store derives
    synchronously from an async source, its pending state is not a declared
    flight, so every transaction holding overrides on the store parked on it.
    The refetch's landing only re-enters transactions that registered it, so a
    refetch caused outside the action (a router action calls revalidate() after
    an await) left the action's held writes uncommitted: For index accessors
    stayed stale (solidjs/solid-router#619) and an unchanged truth never lifted
    the overlay (solidjs/solid-router#620). An undeclared flight is now owned by
    the transaction stamped on the store's firewall, the same ownership rule
    declared flights already follow.

  • 53498dd: Two optimistic-list holes that halted the scheduler under a keyed mapArray / <For keyed={r => r.id}> (optimistic-list-mutation-matrix findings F5 and F3).

    F5 — a derived optimistic store (createOptimisticStore(() => data())) whose truth lands with a different length than the optimistic frame (a server-assigned row beside the one the action added, a rejected add, another row in a deleted slot). The landing supersedes the store's length and presence overrides (#3331): tracked reads already served the staged truth through serve, but the untracked store paths — the length view, the has trap, ownKeys / descriptors, snapshot() / deep() — still composed the override. mapArray reads the list tracked and then untracked inside its owner, so its item snapshot came up short or holey and the next pass handed the key function undefined. Every untracked channel now takes the same reader-aware selection as get (readerOverride: a superseded override answers as serve does — the staged truth to a deriving pass, the override to a lane pass or a context-free read, A18). Regressed in #3370 (shipped in rc.9).

    F3 — a second tentative draft opened while a prior draft's overrides are live (two pending actions, or two setter calls in one action). The length draft arm re-composed the prior draft's overrides onto the draft's already-seeded backing, so after splice(from, 1) the length read one too long and the second splice left a hole; it now gates on draftSeesOverrides like every other draft channel.

  • c8a9d23: An armed derived override shields an untracked read of an uninitialized node (A18 (d), follow-up to #3651)

    A memo whose first async landing rode its optimistic lane keeps STATUS_UNINITIALIZED (its value sits in the derived-override slot until the lane's commit promotes it). When the action then fails, the memo re-derives from the truth and is pending in the body-end window while its override is still displayed. An untracked read of it in that window — from ambient code, with no reader identity — threw NotReadyError: read()'s uninitialized-pending throw fired ahead of serve()'s override arm. It now returns the override, as the same read of an initialized node re-deriving under its override already did. Tracked readers are unchanged: lane readers keep the override, off-lane readers suspend.

  • fd36d37: Fold the source-name plumbing out of the production artifacts: createStore's declared name is recorded in an observe-only branch of the public entry (no extra parameter on the store constructor), the web spread labels ride a module-level spreadName that effect and insert read while the spread body runs (no options argument at the call sites), and the boundary node names gate the call or set _name after it. The minified prod bundles are structurally identical to next (identifier-normalised diff empty); the observe-tier caps are ratcheted for the performance-tracks records with an audit note.

  • fd36d37: @solidjs/web/performance-tracks: a Propagation track (replacing Scheduler)

    Nothing re-renders in Solid, so React's component flame has no counterpart here; the picture a Solid developer wants is the graph a write travelled. The Propagation track paints each scheduler drain as a wave named by the writes that started it and what they reached (count 0 → 1 — click on button#next · 5 runs, 1 unchanged), and every run inside it — compute runs, creation runs, effect callbacks — at its own time, labelled by what made it run (<TodoRow> › effect ← doubled). The panel stacks the runs beneath their wave by time, so a wide flat wave is a coarse signal everyone depends on, a deep one a chain of memos, and a warning node with nothing after it the equality cutoff at work; a wave that mostly re-ran unchanged nodes is itself a warning. The wave span ignores minMs, so a fan-out of runs too small to paint still reads as its count.

    Node labels fold framework structure into what the developer wrote: a flow control's own nodes (<Show>'s condition value / condition / value, a boundary's children / boundary / value, <Switch>'s conditions, <Reveal>'s reveal order) present as the tag, and a primitive.local name (the store convention, and what the compiler will emit for a composed primitive's internals) as the primitive — with the runtime's name kept in the span's Node property. Presentation only: the records are what the engine delivered.

    Engine: ChangeRecord carries nodeId (the written signal's, or the changed memo's — the same id space as RerunEvent.nodeId), so a derived cause joins the run that produced it and repeated writes to one signal join each other after the record has left the process. @solidjs/signals' boundary nodes and <Switch>'s condition-builder memo are now named in observe builds (children, boundary, value, reveal order, conditions) where they read as anonymous computeds in owner paths before.

  • fd36d37: @solidjs/web/performance-tracks: one instance per page — a second enablePerformanceTracks() joins the running one and returns its own release (HMR re-evaluation no longer paints every span twice or strands a hold); the ./performance-tracks export resolves to the inert artifact under the node/worker/deno conditions so an SSR pass takes no engine hold and emits nothing; host performance/console calls are guarded so a throw drops the entry instead of propagating into the engine's record loop; rich: false falls back to performance.measure where console.timeStamp is missing; rich mode clears only the User Timing names it owns outright, leaving an app measure that shares a label alone. Engine: fallbacks staged on a transaction are held weakly, so a transaction dropped without settling releases them.

  • 310116a: Behavior change: a createProjection / createStore(fn, seed) / createOptimisticStore(fn) draft now stays valid until it is superseded by the next run of the derive or its owner is disposed — no longer "until no longer in flight" (#3585). A subscription set up inside a sync derive can write through the draft from its callback without the never-resolving-Promise workaround; the write applies, notifies subscribers, and arms the flush itself when no async run is in flight to do so (the scheduler used to strand on such writes). Also fixes the pre-existing strand where a sync projection created in a top-level createRoot outside any flush left the scheduler armed with no microtask — its effects and every unrelated write waited for an explicit flush(). Writes through a superseded or disposed draft are dropped silently, so a previous run's leaked callback can no longer write into the current run's state (it did before, whenever nothing had read the projection yet).

  • 4b62bc2: A computation that disposes its own owner during its recompute (a memo calling its root's dispose()) stays disposed: recompute's finally now carries REACTIVE_DISPOSED alongside REACTIVE_ZOMBIE instead of dropping it, and the pass is void — dependencies re-linked after the dispose() call are unlinked, a flight it started is retired, and its value is neither committed nor propagated (a dead node freezes at its last committed value). isDisposed() reads true and refresh() no longer re-runs it into a torn-down tree (#3621).

  • 974506c: The solid-js/refresh HMR memo is now framework plumbing to the observe tiers: an internal _plumbing memo option (CONFIG_PLUMBING) leaves it unnamed, out of every owner path, and unrecorded by the attribution engine — no creation or re-run record of its own — while the component body it runs stays fully observed. Replaces the empty-name workaround from #3629, which still produced a blank-named create record per component mount.

  • dd53561: "render" record and AttributionOptions.values — the two remaining places where the observe surface duplicated a record or scrubbed one after the fact.

    "render" record (@solidjs/web, server). A server render — renderToString or renderToStream — is now a record on OBSERVE.records: RenderEvent { mode: "string" | "stream", at, shellMs?, durationMs, boundaries, outcome: "complete" | "abandoned" | "error" }, delivered when the render ends, with RenderLive { event?: RequestEvent, trace: TraceContext } beside it. shellMs is render start → the shell complete (the stream's shell handed to the sink; the string's document assembled); boundaries counts the <Loading> boundaries the shell waited on. Types RenderEvent, RenderLive, RenderListener are exported from @solidjs/web.

    The response's Server-Timing metrics are now strictly projections of records, one gate each (observed(type) || dev): solid-invocation from the "invocation" record, solid-shell from the "render" record's shellMs, solid-boundary from each "boundary" record the shell waited on — computed from the record objects at head commit, no second push. Wire format unchanged. Behavior change (observe tier): solid-shell now rides the "render" listener, not the "boundary" listener; an observe deployment that subscribed to "boundary" alone keeps its solid-boundary metrics and needs a "render" subscription for solid-shell. Dev builds still write all three always.

    AttributionOptions.values: "full" | "labels" | "none" (@solidjs/signals, re-exported by solid-js/attribution). One engine option governs the user-data fields of the engine's records at the source: ChangeRecord.prev/value, HeldWrite.prev/value, ChangeOrigin.target (and so InteractionEvent.target, HoldEvent.interaction.target), and every sentence built from them (formatRerun, formatOrigin, SILENT_HOLD/LONG_HOLD, OPTIMISTIC_REVERTED). "full" is today's dev output; "labels" drops value previews and keeps element text only on a button or an a; "none" drops both. The default is the build tier's: "full" in dev builds, "none" in observe builds (folded at build time — the observe engine ships "none" only). Across holds the least permissive level wins; a holder naming no level asks for the tier's default, so in an observe build it tightens to "none" beside anyone, while a single holder passing "full" there gets "full"; an explicit "full" never loosens what another holder demanded. Observe-tier consumers that export records should pass their level explicitly and treat it as their export contract.

    Removed: PerformanceTracksOptions.scrub and the adapter's scrub helpers. @solidjs/web/performance-tracks paints what the engine put on the record: an observe build's tracks inherit "none" (tighter than the old scrub — no element text on buttons/links either); the old observe posture is enablePerformanceTracks({ attribution: { values: "labels" } }). Behavior change: a finding's marker always carries event.message.

    Internal: solid-js's server render context seam _timing became _recordBoundary(event: BoundaryEvent) — the boundary files its record, the web runtime projects the header from it.

  • e10a4ba: SSR element and props-view fast paths for the per-element hot path. ssrElement takes an optional trailing attrs — attribute markup the caller already holds (a spread element's trailing attributes, a class a library computed and knows is clean), a string or a thunk called after the sources are walked — appended after the props' attributes in place of a { class, style } source that was built, keyed and precedence-walked on every render; ssrElementAttribute(key, value) (compiler primitive) serializes one attribute by the spread walk's rules for such a thunk. Per tag name, ssrElement remembers one record — the <tag and </tag> markup, void, textarea, raw-text — in place of building both strings and testing the name on every element; the attribute-name escape is remembered per name; and every source body is read as props[prop] (a plain read or the proxy's trap, which is all the source helper did for these kinds), with only the key list depending on the source's kind. On a button with four props, a skip predicate and a computed class, ssrElement runs within 9 ns/element of a hand-written writer that knows its tag and keys (from 22). merge() sizes its source arrays up front and its get trap walks plain sources directly, reading before the in check. A $PROXY-marked source is classified by ONE read of a new internal brand, $RECORD — a merge or omit view answers its record, a store answers undefined on its symbol fast path, a proxy that does not know the key forwards it to a target without it — where $TARGET and then each view kind was two to four trap hops per source at every merge(), omit(), isStatic and ssrElement (a merge over an omit view: 81 → 50 ns; an omit over a merge: 63 → 36 ns; ssrElement on a view: −8%). The private $SOURCES/$OMIT/$VIEW symbols are retired for it; a store-shaped proxy that implements the brand protocol should answer $RECORD with undefined to keep the read off its generic path. No output changes.

  • 1f40560: Enumerating a store object inside a computation (Object.keys, for...in, spread, Object.entries, JSON.stringify) no longer materializes a presence node per key (#3664). The descriptor trap's presence read (rc.9, for a lone Object.getOwnPropertyDescriptor inspection) also ran once per key of every enumeration, on top of the key-set node ownKeys had already subscribed the reader to — ~640 B and a graph node per key per object, 10x the memory of a 30-field row's reader versus rc.8. The trap now skips the presence read when the observer already holds the object's key-set node in the current pass; a lone descriptor read keeps its per-key precision and its reactivity to optimistic adds and deletes.

  • c1b68d9: An interaction whose handler returns a promise stays open until it settles (InteractionEvent.continuationMs, settledMs covers the wait, 10s cap), and UNTRACKED_ASYNC_HANDLER names a handler that awaited past its frame with no write before the await and no action() — the dead click no hold could judge. AttributionHooks.interactionEnd receives the handler's return value.

  • eb3d699: Dev: warn with UNTRACKED_READ_AFTER_AWAIT when an async computation first reads a signal, memo, or store property after an await. Such reads are not dependencies, so the computation silently keeps its old result when they change. The check attributes the read to its computation through V8 async stack traces (Chromium browsers, Node, Deno, Bun; silent on other engines), runs only in dev builds, keeps dev settle timing identical to production, warns once per computation per signal/memo and once per store, and does not blame a continuation for reads made by effect callbacks, cleanups, or action() bodies it triggered.

  • 709c02b: Fix effect writes remaining held when a pending memo is superseded by a synchronous result equal to its cached value. Recheck parked transactions after the source settles synchronously, since the superseded promise can no longer release them when it resolves.

  • c25d69f: WASTED_RECOMPUTE (warn, perf): a scope whose runs in a window were mostly no-ops — inputs changed, the result compared equal, the compute was discarded — named with the input that keeps triggering it. The sixth cost check under checks; wastedRecompute: { minRuns, ratio, budgetMs, windowMs } | false configures it.

  • 27bb3fa: Dev-only owner-chain invariant on the disposal splice (#3543 follow-up). disposeChildren unlinks a self-disposing node from its parent's child chain by position: a node with no _prevSibling is written up as the chain's head. The only way that is false is a node flagged live that sits elsewhere — the #3543 shape, a zombie that lost REACTIVE_ZOMBIE — and the write then clobbers the head with a stale _nextSibling, orphaning every live child ahead of it. Dev builds now assert parent._firstChild === node at that write and report [INVARIANT_VIOLATION] owner-chain-head (thrown under __TEST__, console.error diagnostic in dev). The check is __DEV__-guarded and folds out of the prod and observe tiers (size unchanged).

    Also pins the create-pass shape of #3543: a lazy memo zombified by its owner's rerun and first read from the owner's new pass goes through recompute(comp, true), whose flag wipe must carry REACTIVE_ZOMBIE too.

  • 9e65de2: Zombies of a committing owner do not rerun before the commit (#3546). While any transaction is parked, the ambient flush reruns the zombie queue so the zombies of parked owners — still on screen until the commit that disposes them — follow mainline writes. It ran that queue before committing the flush's own pending nodes, so an owner that recreates a child every pass (the compiled <Show when={n() > 0 && n() < 2}> condition) paid twice per write: its old child, dirtied by the same write, reran as a zombie and notified the owner through the previous pass's dependency tail (kept by A30 until the commit trims it), and the owner recomputed a second time with identical inputs, creating and disposing one more child. The commit now runs first; a zombie whose owner commits this flush is disposed by it and never reruns, exactly as when no transaction is parked. Only the zombies of actually parked owners rerun. A30 is unchanged. Measured on the #3543 shape: 40 alternating writes under a parked action cost 60 nested-memo runs instead of 100, and the owner runs 40 times instead of 60.

Don't miss a new solid release

NewReleases is sending notifications on new releases.