@solidjs/signals@2.0.0-rc.10
Pre-releasePatch Changes
-
fd36d37:
attribution.enable(opts)now returns the release of the hold it takes (idempotent, likesubscribe), 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,historyLimitis the largest), so a hold adds to what the engine does and never takes away what another asked for — a track enabled withlog: falsebeside 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 callingdisable()once cannot strand a hold.enablePerformanceTracksreleases through the token. Dev builds create a component'sconsole.createTasktask 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, likesubscribe). 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,historyLimitis 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(defaulttrue).falseturns 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. isSilentHoldandisLongHoldare exported from@solidjs/signals/attribution(inert in prod), so consumers apply the engine's own hold verdicts instead of thresholds of their own.InteractionEvent.atis now the browser event's owntimeStampwhen@solidjs/webdispatches the handler (guarded against epoch-clock stamps), so it equalsPerformanceEventTiming.startTimefor the same interaction — a direct join to INP. NewInteractionEvent.inputDelayMsreports event creation → handler entry;handlerMsis now entry → return, andsettledMscontinues to measure fromat.
-
fd36d37: Attribution engine: timeline records, and a
flushStarthookFive 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) andfallback(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.subjectOfanswers for the node-bearing ones.Core: a
flushStarthook besideflushEnd(one drain, never nested), andeffectRunStartnow fires in observe builds like itseffectRunEndtwin, so writes inside effect callbacks carry theireffectorigin 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-trackspaints the new records: creation runs and effect callbacks on theEffects/Memostracks, drains on thePropagationtrack (one wave per drain), flights and fallbacks on a newAsynctrack. -
43fae6e:
onCleanupcallbacks 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'ssourceNames.primitivesoption (a@solidjs/vite-pluginoption, not a compilertransform()option — the compiler exposes it as this standalone function). Reactive primitives (createSignal,createMemo,createOptimistic,createStore,createOptimisticStore,createProjection) resolving to asolid-js/@solidjs/signalsimport 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 explicitname. Plain JavaScript in and out, so it runs on.ts/.jsmodules too.Stores honour
name:createStore(value, { name: "todos" })labels its property nodestodos.titlein attribution output instead ofstore.title; derived and optimistic stores name their property nodes alongside the projection node. -
c74365d:
createRootJSDoc now states that a root created inside an owner is disposed with it (detach withrunWithOwner(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'sinsertat the statement, the server'sssr()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.Elementexcludes 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 raiseUNSCOPED_HOLE_ALLOCATED_IDS(warn, kindrender, 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.afterafter 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-arityfallback={() => <F />}thunk built by the consuming hole is that shape).data.nameis 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 bothsolid-jsfacades (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 ofsolid-jsand@solidjs/webare byte-identical to before. -
6717d35: Diagnostic code consolidation. Codes are public API (the Sentry fingerprint roots); three renames.
WIDE_WRITEis folded intoHUGE_FAN_OUT— one code, one threshold story. The core's always-on check warns at 2000 subscribers; the attribution engine'sfanOutthreshold (default 250, waswideWrites) warns earlier while it is enabled and reports through the same emitter, sodata.countis the subscriber count and, from the engine,data.writesays which write reached it ("write","refresh","async"). One WeakMap dedupes both reporters (re-warn after another 500).AttributionOptions.wideWrites→fanOut.SERVER_FN_ERROR_SANITIZEDandSSR_ERROR_SANITIZEDare one code,SERVER_ERROR_SANITIZED— the same fact from two roads.data.sourceis"server-function"(severityerror, from@solidjs/web/server-functions) or"ssr"(severityinfo, from the SSR<Errored>/rejection path);data.erroris the original anddata.wirethe replacement on both.ASYNC_WATERFALLis client-only again: the server's boundary-passes verdict is its own code,SSR_BOUNDARY_WATERFALL(kindssr;infofor two sequential waits,warnfor three or more;data: { boundary, passes, sequentialMs }).data.sideis 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'sdispose()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 deliversnext's address when it is not the one showing.Signals: the lane landing in
asyncWritenow calls a userequalscomparator as(prev, next), the order every other commit path uses (it passed(next, prev)). -
fd36d37: The
fallbackattribution record (and the feedback fold'sshows/flashes) times the fallback's display, not the boundary's swap. A<Loading>swap is a staged write that lands with its frame (#3575:onfollows the frame); the engine now holds the show under that transaction and stampsatat 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 (theLOADING_ON_OUTSIDE_HOLDshape) — was never on screen and produces no record.AttributionHooks.boundaryFallbackgains a fourth argument, the transaction the swap is staged in (nullfor 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(aLoadingfallback shown under 150ms),STACKED_HOLDS(3+ interactions waiting in one hold when it committed). NewAttributionOptions:abandonedFlights,fallbackFlashes,stackedHolds;FALLBACK_FLASH_MSis 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
_disabledswitch, 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 wrappedquery()orasyncarrow 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>/onErrorin every tier). Move the async upstream (createAsync/createMemoread synchronously by the source) or uselazy().Wire shape: each async
dynamic()instance now writes one hydration record, and everydynamic()instance allocates one more hydration owner id (keys under adynamic()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
_valuenever committed. ThesettlePendingSourceinvariant no longer reportsSETTLE_WALK_UNINITIALIZED_SOURCEfor 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 fabricatedundefined. Anuntrack(() => isPending(x))inside a memo no longer enrolls the memo as a suppressed probe, so the router'squery()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 theREACTIVE_DISPOSEDflag 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 (createRootdisposer,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).
recomputeandupdateIfNecessaryrewrote_flagswholesale and droppedREACTIVE_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 commitdisposeChildrenthen 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, untilHUGE_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
liveserver 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 checkSSR_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 hydratingdynamic()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 adynamic(() => 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 agraphattribution record (GraphEvent,GraphSize), and exportsgraphSize()fromsolid-js/attribution.graphGrowth: { visits, ratio } | falseconfigures 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 asprev. 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 optimisticpush/splicefollowed by a longer refetch handed<For>aHOLE: the pass read the landedlengththrough 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 confirmedv1besidesaving=true). Untracked reads, stale readers of a foreign transaction, and lane passes — owning transaction or not — keep committed until the reveal;latest()anduntil()'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 fromonSettledduring 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
inserteffect 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.
optimisticWritepassed whateverresolveTransitionreturned toinitTransition, and for an orphan override that is null, whichinitTransitionreads 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, andactiveTransitionstayed set for the rest of the caller's block. Thelatest()shadow is such a node: its first companion sync installs the orphan override and the second one hit this path, so after onelatest(() => store.value)the second and every latersetStorewrite 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, 0where the same writes without thelatest()read served0, 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 (whyshareshistory("rerun")'s gate;subscriptionsreads the graph and is unaffected); ratchet the engine-cost tripwire cap to 4. -
c9e1954: Loading
onfollows the frame (#3540). When a dependency ofonchanges, 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 readingproduct(id)outside a<Loading on={id()}>that readscomments(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()inon(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_HOLDwith 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.Erroredno longer acceptson(norcreateErrorBoundaryanonoption). It was added in #3556 and never released in a stable — rc-only. Retry through theresetthe fallback receives (fallback={(err, reset) => ...}), or re-mount the boundary on the dependency (<Show keyed when={id()}>). -
55779c0:
Loading'sonprop 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()insideonis 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.
Erroredaccepts the sameon: while it shows its error fallback, a change to a dependency clears the caught error and retries the children (reset keys).createErrorBoundarytakes{ on }as its third argument.Boundaries are exempt from A29 born-held: a
Loadingmounted 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_HOLDnow recommends the structural fix (one hold owns the data, orisPending()for the wait) and mentionslatest()inononly as a capability. Docs and JSDoc forLoading onupdated to match. -
28fcc9b: The DEV
LOADING_ON_OUTSIDE_HOLDdiagnostic now reports only the deterministic shape:onre-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.sourcenames 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, theAttributionHookstype,DEV.setConsoleFooter, theTraceSlotandOriginRefaliases,PerformanceTracksOptions.group, and the untestedsolid-js/refreshruntime modes (esm,webpack5,rspack-esm) are gone —@solidjs/compiler'stransformRefresh({ bundler })accepts only"vite" | "standard"to match.ownerPath()is nowOBSERVE.ownerPath(subject)anddiagnosticGuideUrl()isDEV.guideUrl(code);why()also accepts a scope name. Engine record types (RerunEvent,HoldEvent, …) live onsolid-js/attributiononly, andInteractionRef/NavigationRefon the main entries only.@solidjs/diagnosticstypes its record tables off the runtimes' own catalogue (RecordEvent<K>), adds therecoverytable, and bumps the artifactformatVersionto 8. -
f2bd662:
OBSERVE.subjectOfJSDoc names thesubscriptions()export ofsolid-js/attributionrather than the removedattribution.subscriptions()method -
756b1b3:
omit()'s no-Proxy copy path re-homes accessors with the source as receiver instead of forwarding the descriptor, matchingmerge()'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) areRecordTypesentries delivered onOBSERVE.records.subscribe(type, (event, live) => …), with the live node beside each record. Removedattribution.subscribe(both overloads),OBSERVE.subjectOf, theAttributionRecords/AttributionRecordTypetypes, and theisSilentHold/isLongHoldhelpers —HoldEventnow carriessilentandlong, computed at settle.attribution.history(),waterfalls(),holds(),navigations()andinteractions()collapse intoattribution.history(type).DiagnosticListenerreceives the subject as its second argument. The channel allocates nothing per emit (copy-on-write listener lists), and aRerunEventis built only while a listener, a fold or the log wants it. Record listeners belong to the channel and are no longer dropped byattribution.disable(). -
658eecd: Fix a chained optimistic store dropping a write back to the base's previous value (#3672)
createOptimisticStore(base)over acreateStoreserves the base's live value; its own nodes are links whose_valueis 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 (positionfrom 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 — acreateMemoover 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 readundefinedwhere the first setter had pushed a row —lengthandinsaw it, the value read did not, and afindIndexlookup missed. The draft is the writer's channel and composes on the tick's own unflushed writes (hasActiveOverride), as the has trap,visibleKeysandoptimisticViewalready did. - The descriptor trap gains the same draft arm:
ownKeyslisted an added key whilegetOwnPropertyDescriptorreported 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.
- rc.9 regression: the get trap's draft arm for an absent own key was gated on the reader rule (
-
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.optimisticRevertedis the seam;optimisticReverts: falsedisables it. The runtime's own optimistic nodes (isPending/latestcompanions, 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 callsrevalidate()after
anawait) left the action's held writes uncommitted:Forindex 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'slengthand presence overrides (#3331): tracked reads already served the staged truth throughserve, but the untracked store paths — thelengthview, thehastrap,ownKeys/ descriptors,snapshot()/deep()— still composed the override.mapArrayreads 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 functionundefined. Every untracked channel now takes the same reader-aware selection asget(readerOverride: a superseded override answers asservedoes — 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
lengthdraft arm re-composed the prior draft's overrides onto the draft's already-seeded backing, so aftersplice(from, 1)the length read one too long and the second splice left a hole; it now gates ondraftSeesOverrideslike 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 — threwNotReadyError:read()'s uninitialized-pending throw fired ahead ofserve()'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 webspreadlabels ride a module-levelspreadNamethateffectandinsertread while the spread body runs (no options argument at the call sites), and the boundary node names gate the call or set_nameafter it. The minified prod bundles are structurally identical tonext(identifier-normalised diff empty); the observe-tier caps are ratcheted for the performance-tracks records with an audit note. -
fd36d37:
@solidjs/web/performance-tracks: aPropagationtrack (replacingScheduler)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
Propagationtrack 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 awarningnode with nothing after it the equality cutoff at work; a wave that mostly re-ran unchanged nodes is itself awarning. The wave span ignoresminMs, 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>'scondition value/condition/value, a boundary'schildren/boundary/value,<Switch>'sconditions,<Reveal>'sreveal order) present as the tag, and aprimitive.localname (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'sNodeproperty. Presentation only: the records are what the engine delivered.Engine:
ChangeRecordcarriesnodeId(the written signal's, or the changed memo's — the same id space asRerunEvent.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 anonymouscomputeds in owner paths before. -
fd36d37:
@solidjs/web/performance-tracks: one instance per page — a secondenablePerformanceTracks()joins the running one and returns its own release (HMR re-evaluation no longer paints every span twice or strands a hold); the./performance-tracksexport resolves to the inert artifact under thenode/worker/denoconditions so an SSR pass takes no engine hold and emits nothing; hostperformance/consolecalls are guarded so a throw drops the entry instead of propagating into the engine's record loop;rich: falsefalls back toperformance.measurewhereconsole.timeStampis 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-levelcreateRootoutside any flush left the scheduler armed with no microtask — its effects and every unrelated write waited for an explicitflush(). 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'sfinallynow carriesREACTIVE_DISPOSEDalongsideREACTIVE_ZOMBIEinstead of dropping it, and the pass is void — dependencies re-linked after thedispose()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 andrefresh()no longer re-runs it into a torn-down tree (#3621). -
974506c: The
solid-js/refreshHMR memo is now framework plumbing to the observe tiers: an internal_plumbingmemo 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-namedcreaterecord per component mount. -
dd53561:
"render"record andAttributionOptions.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 —renderToStringorrenderToStream— is now a record onOBSERVE.records:RenderEvent { mode: "string" | "stream", at, shellMs?, durationMs, boundaries, outcome: "complete" | "abandoned" | "error" }, delivered when the render ends, withRenderLive { event?: RequestEvent, trace: TraceContext }beside it.shellMsis render start → the shell complete (the stream's shell handed to the sink; the string's document assembled);boundariescounts the<Loading>boundaries the shell waited on. TypesRenderEvent,RenderLive,RenderListenerare exported from@solidjs/web.The response's
Server-Timingmetrics are now strictly projections of records, one gate each (observed(type) || dev):solid-invocationfrom the"invocation"record,solid-shellfrom the"render"record'sshellMs,solid-boundaryfrom 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-shellnow rides the"render"listener, not the"boundary"listener; an observe deployment that subscribed to"boundary"alone keeps itssolid-boundarymetrics and needs a"render"subscription forsolid-shell. Dev builds still write all three always.AttributionOptions.values: "full" | "labels" | "none"(@solidjs/signals, re-exported bysolid-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 soInteractionEvent.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 abuttonor ana;"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.scruband the adapter's scrub helpers.@solidjs/web/performance-trackspaints 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 isenablePerformanceTracks({ attribution: { values: "labels" } }). Behavior change: a finding's marker always carriesevent.message.Internal:
solid-js's server render context seam_timingbecame_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.
ssrElementtakes an optional trailingattrs— 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,ssrElementremembers one record — the<tagand</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 asprops[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,ssrElementruns 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 itsgettrap walks plain sources directly, reading before theincheck. 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 answersundefinedon its symbol fast path, a proxy that does not know the key forwards it to a target without it — where$TARGETand then each view kind was two to four trap hops per source at everymerge(),omit(),isStaticandssrElement(a merge over an omit view: 81 → 50 ns; an omit over a merge: 63 → 36 ns;ssrElementon a view: −8%). The private$SOURCES/$OMIT/$VIEWsymbols are retired for it; a store-shaped proxy that implements the brand protocol should answer$RECORDwithundefinedto 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 loneObject.getOwnPropertyDescriptorinspection) also ran once per key of every enumeration, on top of the key-set nodeownKeyshad 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,settledMscovers the wait, 10s cap), andUNTRACKED_ASYNC_HANDLERnames a handler that awaited past its frame with no write before theawaitand noaction()— the dead click no hold could judge.AttributionHooks.interactionEndreceives the handler's return value. -
eb3d699: Dev: warn with
UNTRACKED_READ_AFTER_AWAITwhen an async computation first reads a signal, memo, or store property after anawait. 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, oraction()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 underchecks;wastedRecompute: { minRuns, ratio, budgetMs, windowMs } | falseconfigures it. -
27bb3fa: Dev-only owner-chain invariant on the disposal splice (#3543 follow-up).
disposeChildrenunlinks a self-disposing node from its parent's child chain by position: a node with no_prevSiblingis 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 lostREACTIVE_ZOMBIE— and the write then clobbers the head with a stale_nextSibling, orphaning every live child ahead of it. Dev builds now assertparent._firstChild === nodeat that write and report[INVARIANT_VIOLATION] owner-chain-head(thrown under__TEST__,console.errordiagnostic 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 carryREACTIVE_ZOMBIEtoo. -
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.