Patch Changes
-
d826cd3: The client error hook —
configureClientErrors({ onError })fromsolid-js, andrender/hydrate'sonErroroption in@solidjs/web: the prod-tier seam through which an app, or an error monitor, hears the one failure nothing else can see — an<Errored>/createErrorBoundarycollected it and renders its fallback. The twin of the server'sconfigureServerErrors. An uncaught error is not this hook's: the halt (REACTIVITY_HALTED) hands its cause to the platform'sreportError, the channel every monitor already listens on.Once per error object (a
reset()re-collecting the same failure says nothing new; a primitive is reported per sight);ownerPathcarries the component labels where the runtime keeps owner names; no return — the client has no wire to map for; a throwing hook is reported and ignored. A root's own hook wins over the ambient one for failures under it.Pay-for-use: the hook machinery (
core/error-hooks.ts) is retained bycreateErrorBoundaryor the app's ownconfigureClientErrorsimport; a root's hook is parked on the root owner under the registeredROOT_ERROR_HOOKsymbol (defined in the scheduler), sorenderretains nothing for an app that passes none. Core floor unchanged; apps with a boundary +~200 B.The server surface consolidates on the same name.
onErroronrenderToStream/renderToStringand onhandleServerFunctionRequestis the server error hook ((error, context) => wire | void);onServerErroris removed. A one-argumentonErrorwritten for the old shape keeps working and now hears every handled failure — filter oncontext.handling === "failed"for the request-failing ones alone. Newhandling: "serialize"for a hydration value that would not serialize (what seroval'sonErrorreported, for a render that passed one). With no hook anywhere, a failure that fails the request still reachesconsole.error. -
6d2bdeb: Move delegated event handlers off the
$$<type>element key Solid 1 uses.Solid 1 delegates from
documentand fires any$$click/$$input/… it finds while walking up from the target, so a 1.x runtime on the same page — an older embedded widget, a devtools panel built on 1.x — ran every delegated handler in a 2.x app a second time. Compiled output and the runtime now stamp_$$<type>/_$$<type>Datainstead; neither version can see the other's handlers, in either nesting direction.The key, the
_$SOLID_EVENT_OWNERmark, and the walk rules are documented inclient.tsas the delegated-event wire contract shared by every Solid copy on a page. Anything readingel.$$clickdirectly must switch toel._$$click. -
17b0bda: Deprecate
<Dynamic>in favor ofdynamic()<Dynamic component={…}>is the same primitive asdynamic()with a worse shape: the tag travels in the props bag, so every instance mergescomponentin at the call site,omits it back out inside, and builds a freshdynamic()factory (with its memo) because a JSX wrapper has nowhere to hoist one. Polymorphic-component libraries end up omittingas, handing the tag to<Dynamic>, which merges it back in undercomponentto omit it again.dynamic()has none of that for one extra line:// before <Dynamic component={multiline() ? RichTextEditor : "input"} value={value()} />; // after — hoist per component instance, or per module for a constant tag const Field = dynamic(() => (multiline() ? RichTextEditor : "input")); <Field value={value()} />;
DynamicandDynamicPropsare marked@deprecated(editors strike them through; no runtime warning). They remain available in 2.0 — the RCs are past public API removals — but new code should usedynamic(). The migration guide and control-flow RFC are updated accordingly. -
1af28a1: Call
runHydrationEvents()afterdynamic()spreads props onto a string tag, so events the hydration script queued for a<Dynamic>element are replayed instead of being dropped until_$HY.done. -
d2a36f5:
dynamic(source, { static })andisStatic(o, key)dynamic()pays for a factory memo plus a per-instance memo so the source can
change. A great many call sites never change: a runtimestyled()that always
renders"li", and — the case this exists for — a polymorphic component whose
asarrived as a literal. The compiler encodesas="button"at a call site as
a data property andas={isLink() ? "a" : "button"}as a getter, so which one
the caller wrote is readable at runtime.isStatic(o, key)reads it: one descriptor lookup, no read of the value,
nothing tracked, looking throughmerge()/omit()views to the leaf that owns
the key. Data property or absent-from-a-fixed-key-set is static; a getter, a
store key, or a memo-backedmerge()source is not.dynamic(source, { static: true })then says the source cannot change: it is
called once, untracked, atdynamic()time, and each instance renders the
result with no computation of its own — a tag goes to the compiled element path
(create or claim, spread), a component is called directly. No owner is created
on either side, so hydration ids stay aligned between server and client. A
static source may not return a promise.function Polymorphic(props) { const Tag = dynamic(() => props.as, { static: isStatic(props, "as") }); return <Tag {...omit(props, "as")} />; }
asstays public and reactive; the literal case stops paying for it. Note the
two paths produce DIFFERENT hydration ids (the memo path's element sits one
owner deeper), which is fine becauseisStaticreads the same descriptors on
both sides — but it is why the classification must be per instance rather than
per component. -
c452850:
dynamic()/Dynamicwith a tag-name source honor anxmlnsprop when creating the element (#3386)The compiler resolves a tag's namespace from its parent at build time; the
dynamic()runtime path creates the element before it has a parent, so a tag that exists in both HTML and SVG (a,script,style,title) was always created as an HTML element —<svg><Link href=…/></svg>withLink = dynamic(() => "a")produced an HTML anchor inside the SVG tree. The instance can now say which one it means with the same attribute compiled JSX uses for the same purpose:<Link xmlns="http://www.w3.org/2000/svg" href=…/>. Likeis,xmlnsis read once, untracked, at creation (the DOM can't re-namespace a node) and then applied as an ordinary attribute, so a client-rendered element carries the same attribute the server serializes. Withoutxmlnsthe namespace is still inferred from the tag name. Hydration is unaffected: it claims the parser-namespaced node.Types:
xmlnsis now accepted on the four tags that exist in both HTML and SVG (a,script,style,title) in addition to the SVG/MathML attribute sets that already had it — those are the only tags where the attribute changes what gets created, and the compiler has honored<a xmlns=…>on them all along. Unambiguous HTML tags (div,span, …) still reject it.dynamic()'s tag-name components inherit it through their intrinsic attribute types. Also syncs@solidjs/h's generated JSX types. -
53280e7: The error hooks and
SSR_RENDER_ERROR_CONTAINEDtell where an error was thrown apart from where it was met.ownerPathonClientErrorContextandServerErrorContextis now where the error was thrown: the labels root-first up the owner chain of the computation that threw — the component that broke — falling back to the boundary's chain when the throw crossed nothing the runtime could name. A newboundaryPathis where it was met: the same labels up the chain of the<Errored>that rendered its fallback (client and server) or the<Loading>that shipped the rejection (server,handling: "client"). Before,ownerPathwas the boundary's on both sides, so every component under one boundary grouped into one path. On the client the engine's status wrapper already named the thrower (StatusError.source); on the server the owner scopes stamp it as the error escapes. TheSSR_RENDER_ERROR_CONTAINEDfinding follows:ownerPathlocates the throw,data.boundary/data.boundaryPaththe boundary. -
5b31076:
ssrElementno longer emitsstyle=""/class=""for nullish values; they are omitted like every other attribute, matching the client. Skipped props also leave no stray whitespace in the opening tag (#3382). -
084e621: SSR emits the
<!--!$-->text separator only between items that resolve to text. Adjacent memos and components that yield elements — everyDynamic,Show, or wrapper-library instance in a list — no longer carry a separator and a comment node each. Text that becomes adjacent through a nested array or a dropped nullish item is now separated, so it hydrates into distinct text nodes (#3383). -
a429b44: Server-function flight consumers now receive every mutation response that carries integration metadata — the redirect carrier or
X-Revalidatekeys (redirect(),reload(),respond(value, { revalidate })) — whether or not the server folded data alongside it. Metadata is envelope-level (it names what the mutation did to every cache on the page), so it reaches each registered consumer withdataset toundefinedwhere no slice was collected for that source, and the call resolves with the plain value. An integration that subscribes a consumer therefore owns redirects and revalidation without wrapping the call, and a redirect the server could collect no data for (a cross-origin target, no collector registered) navigates instead of landing on the caller as a rawResponse. Reads (GET orread: true) keep the whole-response passthrough;FlightDataConsumer'sdataparameter is typedD | undefinedaccordingly.revalidategains a reserved all-keys spelling:REVALIDATE_ALL("*"), the host-independent way to declare that every cache entry went stale, distinct from omittingrevalidate(the host's default — Solid Router revalidates everything after an action, a router without that convention reloads only what it owns) and from an empty list (nothing). The helpers refuse"*"beside named keys;ServerFunctionOutcome.revalidateKeysdelivers it to collectors as declared. -
549f482: Trim per-node work on the hydration claim path.
gatherHydratableasks once whether the root contains frame regions and tests containment against that list, instead of walking every keyed node's ancestor chain withclosest("[data-fid]");insert()builds a parent's claim array in one indexed pass overchildNodesthat drops separators as it copies, instead of an iterator spread followed by a compacting pass; andclearSnapshotsassignsundefinedto the extension's_snapshotValuerather thandelete-ing it, which pushed every hydrated source's extension object into dictionary mode. -
3154ed6: A signal written from
onSettledorcreateEffectduring hydration no longer strands the DOM it reveals (#3504). Two halves: a root created while hydrating marks the hydration snapshot scope itself, so a write during the root pass is held until the pass completes and then replays — previously the scope was marked lazily by the first hydration-aware primitive, so a<Show>condition created before that sat outside it and the write cascaded mid-claim. And a streamed boundary's resume window now claims only the subtree under that boundary: a write from the resumed content that reaches a signal above it (a route'sonSettledadding a toast to a provider) re-renders that already-hydrated region as a client render — fresh nodes, live inserts — instead of claiming against the server registry, missing with a "Hydration key miss" warning, and rendering detached. -
b298154: Move the runtimes' seams off the
solid-jssurface and behindsolid-js/internalmerge()/omit()returning lazy views (#3454) gavespread()and
ssrElement()a protocol for reading props leaf by leaf instead of trapping
through the proxy per key. Because@solidjs/weband@solidjs/universal
depend onsolid-jsalone — never on@solidjs/signalsdirectly, so an app
holds exactly one reactive engine — every piece of that protocol went out
throughsolid-js's main export: eleven names,mergeSourcesbefore them in
#3325, on the public surface with no marking. None of it is API.The protocol (
viewOf,mergeView/omitView,MergeView/OmitView,
sourceKeys/sourceHas/sourceGet,hasStaticKeys,resolvedTable, the
SOURCE_*kinds,SourceKind) now lives on asolid-js/internalsubpath,
along with the server-scope seams that were already@internalin JSDoc and
consumed only by@solidjs/web(ssrHandleError,ssrScope,
runInServerComponentScope,inServerComponentScope,creationStamp,
getProjectionTrace,materializeContainerTrace). The names stay exported
from the main entries at runtime, so the subpath shares one module state and
the single-engine guarantee is untouched;stripInternalkeeps them out of
the generated declarations, so TypeScript no longer offers or types them.
internal-surface.spec.tspins the boundary in both directions.Also dropped from the entries:
storeIsShallow,storeHasFamily,
storeHasOptimisticFamily(leftovers of the gutted patch channel),
storePathand$REFRESH(referenced only by@solidjs/signals's own
internals), andNoHydrateContext(@internal, used only bysolid-js's
server code). Nothing in the repo consumed them.No runtime behavior change.
merge,omit, and the rest of the reactive
surface are unaffected. -
899c2c4:
merge()andomit()are always lazy views, and props consumers read their leavesomit(props, ...keys)returns a live view ofpropsfor every input — a plain object included — instead of copying it with agetOwnPropertyDescriptor+definePropertyper prop. A predicate form hides keys by rule without enumerating first:omit(props, k => k[0] === "$").merge()no longer builds an eager copy when its sources are plain objects: underProxyit always returns an O(1) view over the flattened sources (a single non-function source is returned as is).The two compose flat. An
omit()over amerge()carries one filtered view per flattened merge source, amerge()over anomit()takes the view record as a leaf, and nested omits fold their filters into one record. A component chain ofmerge(defaults) → omit(consumed) → merge(statics) → omit("as")— the shape headless-UI libraries render every element through — collapses to leaf views over the original objects, each with its accumulated filter, with no proxy layer left between the outermost spread and the author's props.merge()keeps the omitted keys hidden by construction (#3014) rather than by treating the omit as opaque. Construction cost drops 3–7× at depth 1–7; the SSR polymorphic-chain bench (#3448) runs ~2.4× faster.Reads stay cheap: a view over plain objects resolves a key → owning-leaf table once, on first read, and every
get/has/descriptor is one lookup after that.spread()(DOM and universal) andssrElement()read the leaves directly — never through the proxies' traps — and walk that table when there is one, so an effect rerun costs one read per key, as it did over the copy. Both proxies use a class target and one shared handler (no per-instance closures).A view over a store asks the store nothing but the read. Each source's kind (plain object, omit record, proxy, memo) is decided once, when the view is built, and carried beside it — every brand check on a Proxy is a trap (
instanceofis agetPrototypeOftrap, as expensive as a store read), and store detection goes through$TARGET, a symbol the store'sgettrap answers on its fast path, never its generic tracked-read path.merge(defaults, store)constructs ~30% faster than the copy did and reads ~15% faster;omit(store)reads at parity.The views tell the truth:
Object.getOwnPropertyDescriptor(view, key)reports a data descriptor only when the key is a data property of a plain leaf (the compiler's encoding of a static prop) and an accessor for a getter, a store key, or a memo source. Together with the new internalhasStaticKeys(),spread()now skips the children effect for static children behindomit/mergelayers (#3388 through views).Behavior changes:
- Writes to a
merge()oromit()result are no-ops (they already were for the proxy forms). A caller that needs its own object copies it ({ ...merged }), and the copy carries no sources (#3384).@solidjs/htmlnow collects its own props and spreads into onemerge()at the end instead of assigning onto the result. - A data property on a source is read live through the view rather than snapshotted at
merge()/omit()time. - Key order of a merged view is the merged order — every key at the position of the last source that carries it — matching
ssrElement's array form. - Sources are treated as own-keyed; a key added to a plain source after merging is not seen (the copy did not see it either).
- Enumerating a view through its traps (
for…in,Object.keys,{ ...view }) costs a trap per key, as any proxy does; the internal consumers avoid it. Environments withoutProxykeep the copy paths.
Internal helpers for consumers, exported from
solid-js:viewOf(o),mergeView(o),omitView(o),sourceKeys(entry, kind),sourceHas(entry, kind, key),sourceGet(entry, kind, key),hasStaticKeys(o),resolvedTable(o), theSOURCE_*kinds. - Writes to a
-
3ae9e92:
OBSERVE.records— one records channel on both platforms (observe/dev tiers); frame records from both ends; the client"call"record;observeServerFunctionCallsremoved@solidjs/signals:OBSERVE.records—subscribe(type, listener),observed(type),emit(type, event, live)— the channel every runtime record rides, created once per process and registered onglobalThisunderSymbol.for("@solidjs/signals/observe/records")so a second copy of the core (a bundled server build instrumented through--import) and wire layers bundled without a framework import reach the same listener sets. Listeners are snapshotted per emit; a throwing listener is reported and the rest run. Types:Records,RecordTypes(extendsHostRecordTypes; both declared empty, for the runtimes to augment — one augmenter per interface),RecordType,RecordEvent,RecordLive,RecordListener. Folds out of prod. NewOBSERVE.attribution.currentOrigin()(and thecurrentOriginhook onAttributionHooks): the provenance a root write performed now would be stamped with — the interaction whose handler is running, the navigation/effect/action frame open, or inside a recompute the origin of the change that caused it — as the engine's ownChangeOriginobject,undefinedwhen external or with no engine; for a runtime stamping a record of its own. The installed hooks are also registered onglobalThisunderSymbol.for("@solidjs/signals/observe/attribution"), the same reach-without-an-import the channel has.solid-js: the"boundary"record moves fromOBSERVE.server.recordstoOBSERVE.records(augmenting the core'sRecordTypes).OBSERVE.serverkeeps only thetraceslot;ServerRecordsis gone.@solidjs/web: the"invocation"and"frame"records move toOBSERVE.records(augmentingHostRecordTypesthroughsolid-js). New"call"record (CallEvent,CallLive,CallListener): one per server-function call made from the browser, at the caller's settle —{ id, at, durationMs, method: "GET" | "POST", outcome, status?, origin?, deferred? }with{ args, response?, result? | error? }beside it; joins the server's"invocation"byid, and — throughorigin, the engine's own interaction/navigation object read at dispatch viacurrentOrigin()— the attribution engine'sInteractionEvent/NavigationEvent/HoldEventby identity, so an observer files the call under the click that made it without a time join. The"frame"record now has a client half:FrameEventisFrameProducedEvent | FrameAppliedEvent, discriminated byside, same census on both; the client half (applyFrameResponse, one per stream in a response) addsaddress(theasremap) andoutcome: "truncated"for a body that ended beforecomplete, withlive.response. Server census fix:regionscountshtmlchunks addressed to a child frame id (the former count read a chunk type that does not exist), andshellMsis set by the stream's own shell only. The emitters and their wrappers fold out of the prod client artifacts behind the observe literal (prodapplyFrameResponseand the server-function dispatch are the pre-existing functions, no extra frame or promise hop). The server-functions and frames client entries gainobserveanddevelopmentbuilds and export conditions (server-functions/dist/client.{observe,dev}.js,frames/dist/client.observe.js); the server-functions client is now built with its flags replaced in every tier (before,_SOLID_DEV_there was an unreplaced truthy string).- Removed:
observeServerFunctionCallsand theServerFunctionCall/ServerFunctionRequestCall/ServerFunctionResponseCalltypes, from both server-function entries. Subscribe toOBSERVE.records"call"(client) or"invocation"(server) instead. @solidjs/diagnostics(format v6):artifact.serveris replaced byartifact.records: { boundary, invocation, frame, call }— always present, captured on both platforms including the browser bridge; typesBoundaryRecord,InvocationRecord,FrameRecord(FrameProducedRecord | FrameAppliedRecord),CallRecord(withorigin?: ChangeOrigin),ArtifactRecordsreplace theServer*Record/ArtifactServernames. JSONL: one line per record withtypenaming its table; the meta line'sboundaryCount/invocationCount/frameCountbecomerecordCounts: { boundary, invocation, frame, call }.
-
328580f:
omit()over amerge()is one record that holds the merge's record, not one leaf view per merge sourceAn omit over a merge used to flatten at construction: one
OmitViewplus one combined hidden-key list per flattened leaf, and the nextmerge()copied those entries into its own arrays. On a component chain of defaults + omit + spread (Kobalte-shaped:merge(omit(merge(omit(props)))), four layers) that was ~19 records and as many list copies per element — the largest allocation of the render. The omit now holds theMergeViewrecord itself (a new source kind,SOURCE_MERGE) and is one record however many leaves the merge has; a latermerge()carries it as one entry, and a lateromit()folds into it. Nothing is read through a proxy trap along the way: the entry helpers (sourceKeys/sourceHas/sourceGet,hasStaticKeys, descriptors, the resolved table) recurse into the record by function call.- Reads through the nesting are one walk per read (a nested entry answers presence and value together), and a nested record counts no reads of its own toward the table threshold — the view that was asked decides for the whole tree, and its table is collected in one pass over the leaves rather than one table per layer.
- New
@internalsourceOwners(source, keys, owners): every key of a props source — a plain object, a store, or a merge/omit view — appended in merged order with the object that owns it, in one pass, later sources moving a key to the end.ssrElementcollects any spread that is not plain objects only (a view, a store, the array form with one among them) this way, so each attribute is one direct read of its owner — noinwalk per key through the layers, no key list per leaf, no table, and no per-entry classification (pushEntryis gone). - An omit's
$SOURCESnever answers anything now (previously its filtered leaf views); consumers reach the record throughviewOfand walk it as one filtered entry.
Measured against
next(interleaved, min of N, quiet machine): the tier-1 polymorphic-chain SSR harness allocates 12% less per row (14.6 → 12.8 KB) and is 2–6% faster across the interpreter, Sparkplug, Maglev and TurboFan tiers; the props-chain microbench builds 6–65% faster and builds+consumes 7–31% faster by depth and tier; the omit/merge micro-suite is flat or better in every shape. On the yak-bench SSR lanes with every yak piece on Solid primitives: +7% geomean, +20–36% on the component-composition cases (polymorphic-chain,tabs,multifile-composition), which brings those to parity with yak's hand-rolled runtime. -
da6ed76: SSR: fold the text-separator entry wrappers back into
tryResolveStringandresolveSSRNode. #3394 split each walker into an entry function that reset the separator state and a recursive body; the bodies are too large for V8 to inline, so every template hole paid an extra call. The recursion now passes anestedflag instead — one function each, same output — recovering the ~2% SSR throughput that split cost on element-heavy pages (up to 7% where holes are dense). -
5f688a6: GET-encoded server function calls no longer send the
X-Server-Function-Instanceheader (#3406). A<link rel="preload" as="fetch">is reused only by a fetch that matches it exactly, headers included, so the per-call header made browsers fetch every preloaded read twice. A read's identity is now its url alone; the instance id keeps riding the POST transport, where it still names the call to the handler's hooks ascontext.instance(null for reads, as for no-JS callers).prepareRequestvalidation adapts: on a GET call the method the transport set stands sentinel for a returned init that dropped the original. -
c66130d: Remove the
X-Server-Function-Instanceheader from the server-function wire protocol. Since #3094 it decided nothing: the answer shape is the address (/data/for the scripted transport, bare for plain HTTP), no-JS gating is the address plus a form post, and #3416 had already dropped it from GET reads so preloads could match. What remained was a per-call id the handler copied intocontext.instancefortransformResult/transformFlightResult, which nothing consumed; cross-wire correlation is the trace context's job (traceparent, #3402). Breaking, nominally:context.instanceis gone from the hook contexts, andINSTANCE_HEADERis no longer exported from@solidjs/web/server-functions/{client,server}. A legacy header on an incoming request is ignored, as it already was. TheprepareRequestvalidation (#3174) now uses the transport's method as its sentinel on every call shape, so a hook returning a fresh{ headers }instead of spreading is still refused before dispatch. Client-sideobserveServerFunctionCallsevents keep their localinstanceid for pairing a request with its response. -
36db287:
renderToStringnow disposes its reactive root synchronously before returning instead of viasetTimeout, so a synchronous loop of renders no longer retains every graph until the next macrotask (#3385). The request event's response head is committed right before that dispose — the same head-freeze point an awaitedrenderToStreamalready uses — sohttpStatus/httpHeaderdeclarations still reachcreateSSRResponse. A render that throws leaves the head uncommitted and retracts its declarations as before. -
61a114c: Server boundary records on
OBSERVE.server.records(observe/dev tiers)- New
"boundary"record: one per<Loading>boundary that waited during a server render, delivered when it settles —{ id, at, durationMs, heldMs, passes, outcome: "settled" | "fallback" | "client" | "error", streamed, revealGroup?, ownerPath? }, with the thrown error beside it.idpairs it withSSR_RENDER_ERROR_CONTAINED;passescounts render passes (a sequential chain reads as3+); under a<Reveal>group the record waits for the group's swap soheldMsmeasures how long finished content was held for its siblings. No clock is read without a listener, and the emitter folds out of prod. - The server records channel is
OBSERVE.server.records.subscribe(type, listener)— the server twin ofOBSERVE.attribution.subscribe(type, …).OBSERVE.server.invocations(unreleased) is renamed onto it:subscribe("invocation", …). TheInvocationChanneltype is gone;ServerRecordsis the channel's interface. - Types now layer one augmenter per interface:
@solidjs/signalsdeclaresServerObserveempty;solid-jsaugments it withrecords: ServerRecordsandtrace: ServerTrace, declaring both;@solidjs/webaugments those two through"solid-js".TraceSlotin@solidjs/webis now an alias ofsolid-js'sServerTrace. (Two augmentations of one re-exported interface through different module aliases merge order-dependently in TypeScript — one set was silently lost.) @solidjs/signalsexportsownerPath(subject)— the root-first component-label walk its diagnostics already make — so a record'sownerPathand the finding it pairs with come from the one walk.
- New
-
0d8347a: Server records reach the diagnostics artifact and the dev checks (server-dev-build-plan P4)
@solidjs/diagnosticsartifact format v5:artifact.server: { boundaries, invocations } | nullfoldsOBSERVE.server.recordswhen the scenario runs under the server runtime —captureArtifact(() => renderToStream(…))— one row per<Loading>boundary that waited and per server-function execution;nullfor client captures and the browser bridge. New exported typesArtifactServer,ServerBoundaryRecord,ServerInvocationRecord(mirrors of the runtime'sBoundaryEvent/InvocationEvent; the package still depends on@solidjs/signalsalone). JSONL egress addsboundaryandinvocationlines and the header counts.InvocationEvent.boundary: a direct server-function call made during a<Loading>boundary's render pass carries that boundary's hydration id, the"boundary"record'sid— the join between a boundary's wait and the calls under it.- Two dev checks derived from the boundary facts in
ssrLoadingBoundary:ASYNC_WATERFALLwithdata.side: "server"(passes - 1sequential flights; 2 →info, structured only; 3+ → consolewarn) and a new codeSSR_CLIENT_CONTENT_MASKED(warn,ssr) for client-only content that surfaced only after a real server wait — the server's work discarded, the fallback shown for the wait. Dev tier only; the boundary clock now runs in dev without a listener. solid-js's serveremitFindingkeepsinfofindings off the console (structured channel only), matching the core.
-
7623ce1: Server diagnostics on
OBSERVE.diagnostics;OBSERVE.serverowned bysolid-js's server entryThe server runtime now reports on the same structured channel as the client. Findings — facts about a render, present in observe and dev builds —
SSR_RENDER_ERROR_CONTAINED(a render error a boundary routed;data.handlingisfallback,client, orfailed— the structured face of whatrenderToStream'sonErrorreceives, on the process-wide channel),SSR_SUBTREE_ABANDONED(a failed fragment's pending descendants discarded),SSR_STREAM_ABANDONED(consumer cancelled or sink failed mid-render),LATE_HEADER_WRITE(recorded beside the existing dev throw / prod log),SERVER_FN_ERROR_SANITIZED(the original error the production wire replaced), andFRAME_MARKER_CORRUPTEDfrom the frames client. Checks — dev-only guidance — convert every serverconsole.warnto a code:SERVER_WRITE,REVEAL_IN_RENDER_TO_STRING,LAZY_ASSET_UNMAPPED,PRELOAD_DESCRIPTOR_INVALID,HEAD_TAG_INVALID,BEHAVIOR_CLAIM_DROPPED, andUNRECOGNIZED_INSERT_VALUE(now one code and arenderkind on both platforms);ASYNC_OUTSIDE_LOADING_BOUNDARYon the server records withdata.side: "server"before it throws. Server components are labelled forownerPath(createComponentruns the body under a transparent<Name>owner in observe/dev — no hydration id consumed), soin <App> › <Page>reads the same on both sides, and the server entry installs the same repair-guide console footer as the client. Prod artifacts carry none of it;DiagnosticKindgainsssr,head,render.OBSERVE.server's objects (the invocation listener set, the trace-provider slot) are now created bysolid-js's server entry, once per process underSymbol.for("solid-js/observe/server")onglobalThis, rather than by@solidjs/web's module init: an observer'sinit()that imports onlysolid-jscan subscribe andprovidebefore the web runtime has loaded, and a host that bundles the runtime into its server build and instruments through a--imported module finds one listener set and one provider across both copies. The core keepsserver: {}; the client pays nothing. -
56858e7: The server error hook —
configureServerErrors({ onError })from@solidjs/web,onServerErroronrenderToStream/renderToStringand onhandleServerFunctionRequest: the prod-tier seam through which a server integration hears every failure the runtime handles and may say what the client receives instead (sentry-integration-plan C6, #3468 part 3).Until now a production build reported only the failure that fails a request (
renderToStream'sonError); an<Errored>fallback rendered, a<Loading>fragment rejected and re-rendered by the client, and a server-function throw sanitized onto the wire were observe-tier findings only. The hook is called once per error object, at first sight, with where the failure was met —kind: "render"(fallback/client/failed, with the boundary's hydration id and the component labels when compiled in) orkind: "server-function"(thrown/channel, withfunctionIdanddirectfor an in-process call during SSR) — and the request event. Its return is the wire value: rendered into the fallback, serialized for hydration, sent as the RPC error;undefinedkeeps the default policy (generic outside dev, fidelity in dev); ignored forfailed. A direct server-function call that throws during SSR is reported once as the function's failure, and the boundary that contains it reuses the verdict. Two tiers aswrapInvocationhas: ambient (registered onglobalThisunderSymbol.for("solid-js/server/errors"), shared by a bundled build and an--imported module) and per request, the latter winning. A throwing hook is reported and treated as silent. The hook fires in every tier;onErrorkeeps its meaning.Internals:
solid-js's server entry owns the verdict engine (reportServerError/ssrSanitizeErrorthroughsolid-js/internal, one cache per error object); the SSR context gainserrorPolicyandflushed; the_frrejection reads its verdict at delivery. A rejected async source's own serialized rejection is encoded in the rejection's microtask, ahead of the boundary, and carries the default policy's value — the mapping reaches the boundary's record, which the hydrating client renders from; no tick is added to the error path.SSR_ERROR_SANITIZEDcarriesdata.wire. -
537faea:
serverFunctionUrl(fn, ...args)is now the url aGET()reference's own call requests —<endpoint>/data/<id>[?args=…], built the way the transport builds it, so a<link rel="preload" as="fetch">of it (or a prefetch, a service-worker warm, a fetch by hand) matches the later call in every cache that keys on the url (#3440). It takes the reference, like the rest of the surface, because only the reference knows the call is a GET and how its arguments encode; it throws with a pointer for a reference on the default transport (a POST is not described by its url —invoke(fn, { priority: "low" }, ...args)starts such a call early), for arguments that need the codec, and for a url past the length at which the call falls back to POST.The form-post address the function used to build — what a
<form action>posts to without the runtime, alsofn.url— is nowserverFunctionActionUrl(fn | id, ...boundArgs), withparseServerFunctionActionUrl(url)as its inverse (formerlyparseServerFunctionUrl). The id form remains for the integration that has only an id, reconstructing a callable before the declaring module has loaded. Rendered form actions andfn.urlare unchanged. -
af94f67: Server observe surface:
OBSERVE.serverand the invocation channelOBSERVEgains aserverslot — an augmentableServerObserveinterface declared empty in@solidjs/signals(re-exported bysolid-js), typed and emitted into by@solidjs/web's server runtime, so server-side observability consumers subscribe on the oneOBSERVEobject they already know from the client. The first channel isOBSERVE.server.invocations:subscribe("invocation", (event, live) => …)delivers one{ id, direct, at, durationMs, outcome, deferred? }record per server-function execution — HTTP dispatch and direct SSR calls alike — when it settles, with the request event,request,args, and the result or the error as thrown beside it. Observers, not policy: any number of listeners, none able to alter the call;wrapInvocationremains the single policy hook.@solidjs/webnow publishes observe-tier server artifacts (dist/server.observe.js,server-functions/dist/server.observe.js,frames/dist/server.observe.js) under theobserveexport condition, alongside the existing dev/prod pairs. The surface and every emit site fold out of the prod artifacts. -
042b540: Trace context:
getTraceContext(), W3Ctraceparenton the exchange, and theOBSERVE.server.traceprovider slotThe server runtime now reads the W3C Trace Context half of the HTTP exchange once per request — continuing an incoming
traceparent(withtracestate/baggagebeside it) or originating a trace when none came in — and exposes it throughgetTraceContext()from@solidjs/web:{ traceId, spanId, parentId?, sampled?, state?, baggage?, entries }, one object per request (direct SSR-time server-function calls included), the render's own for a render outside a request scope,undefinedoutside both and on the client. Application code forwards a trace downstream withentries.traceparent. This is core HTTP behavior in every build tier.The trace is also handed down to the browser:
entriesare emitted asServer-Timingmetrics (traceparent;desc="00-…") when the response head commits —createSSRResponse,commitEventResponse(now also for an event without a response stub, such as the server-function handler's default event), andcommitResponseStub(which accepts the owningeventin its options) — and as<meta name="…" content="…">tags in the HTML shell head, delivered wherever the head content goes (</head>splice,onHead). AServer-Timingname the application already wrote is respected;Server-Timingnow folds entry by entry when a stub and a response/responseInitboth carry one. The browser is told only when something is recording the trace — the incomingtraceparentwas sampled, or a provider answered — never for a trace the runtime originated alone or an unsampled upstream one (what load balancers and meshes stamp on every request), so an app with no APM sees zero wire change.In observe/dev builds,
OBSERVE.server.trace.provide(provider)installs a single global provider whose answer merges over the derivation (fields replace,entriesmerge by name) — how an APM's server SDK contributes its active span and vendor entries (sentry-trace/baggage) once, with no per-request entry point into the host. -
7f6332a:
spread()creates fewer reactive nodes per element (#3388):reffolds into the attribute effect and is re-applied only when its identity changes (refs run with no owner, so nothing they create is disposed by the fold); children keep their own ownedinsert— that effect owns the child subtree, and merging it would rebuild the children on every attribute change — but a plain object whosechildrenis a data property inserts the value with no effect at all. Three nodes become two when children flow through the spread, one when they don't.spreadalso accepts an array of sources with an optionalskippredicate —spread(el, [a, b], skipChildren, skip)— the union of own keys with later sources winning, only the winning source read, function sources called inline with no memo (and so no hydration id), matching the serverssrElementarray form. -
a732d2b:
ssrElementserializes the common element shapes without the general resolver's allocations- Children that are one string, a number, nothing, or one finished node now join the open and close tags in place. The general path —
resolveSSRNodeinto a fresh{ t, h, p }result (an object and three arrays), thenssr()over a template array — is taken only for arrays and pending nodes, which need it. Output is unchanged; on a text-content-heavy page this was the single largest cost of a spread element. - The array-sources form over plain objects, and an omit over a merge, walk the array they were handed: the resolved
sources/kindslists (and thefill(SOURCE_OMIT)list) are built only when entries differ in kind. A plain source inpushEntryis classified with one$PROXY incheck instead of two.
On yak's element-dense SSR cases (
dyn-translate,dyn-fair,dyn-inline) this is +15–21% throughput and −27% bytes allocated per instance, closing the gap to the hand-written writer from 1.76× to 1.46×;multifile-composition/tabs+3–6%. - Children that are one string, a number, nothing, or one finished node now join the open and close tags in place. The general path —
-
40977c9:
ssrElementaccepts an array of prop sources and an optionalskippredicate:ssrElement(tag, [a, b, c], children, needsId, skip). The array form serializes straight from the sources with the exact output ofssrElement(tag, merge(a, b, c), ...)— later sources win per key, attributes land in merged order, only the winning source's getter is read (once), andskip(key)drops a key from every source without reading it — so libraries and compilers spreading several sources no longer have to build an intermediate merged object that is walked once and discarded. The array (or a thunk yielding it) is resolved after the hydration key is taken; a function source is a plain thunk called once that creates no memo and consumes no hydration ids. Nullish sources are empty. The single-object form is unchanged. -
34287d8: SSR render failures reach the client sanitized (#3468): the wire policy the server-function handler has applied since #3113/#3116 now covers every SSR road a failure takes — the error an
<Errored>serializes for hydration, a rejected async source serialized into the stream, a<Loading>fragment's_frrejection, a frame stream's error chunks (the fragment's, a live hole's, the root's). Outside the dev build a plain thrown value is replaced with a genericError("Internal Server Error");message,causeand own properties stay on the server. A"use server"function called in-process during SSR never touches the RPC wire, so before this the production page load shipped what that wire withholds.solid-js(server):<Errored>sanitizes before rendering its fallback and serializes the same replacement, so fallback markup and the hydration record agree. A fallback printingerr().messageshows the generic message in production, as for a server-function failure.markSafeError(Symbol.for("solid.SafeError")) passes through with own properties; an Error reached as a value is data and passes as written (#3113's ruling). One replacement per original, however many roads it takes. New findingSSR_ERROR_SANITIZED(info, observe + dev;data.errorthe original), besideSSR_RENDER_ERROR_CONTAINEDwhich carries the failure itself.ssrSanitizeErroris exposed to the runtimes throughsolid-js/internal. Server findings now carry theirownerPathin the observe artifact too — the core's walk reads_parentunder its own build's property mangling and found nothing on a server owner there, so the server entry locates its findings itself.@solidjs/web(server): the hydration serialize funnel guards every channel it writes (a promise's rejection, an async iterable's thrown step); a fragment's terminal error reaches the_frrejection and a transport sink's error chunk sanitized while the abandonment ledger keeps the original; the frame sink's root error chunk and the live-hole/live-attribute error chunks carry the replacement's message.@solidjs/signals: theSSR_ERROR_SANITIZEDcode.
The dev/prod line is the build variant: the
developmentserver artifacts keep full fidelity; production and observe sanitize. -
48f007e:
renderToStreamcompletes when a nested<Loading>settles before its parent fragment fails (#3478). A settled nested fragment parks its markup on the pending parent to be spliced in on resolve; when the parent then rejected, the splice ran over an undefined value, threw out of the fragment's resolver before<key>_frcould reject, and the response waited on it forever. The error path now drops the parked children — the client re-renders the whole subtree off the outer rejection — and the failure is reported once. -
7f5f902:
merge()/omit()views build their resolved key table on enumeration or once reads have paid for it, not on the first read;ssrElementwalks a view's entries instead of asking for its tableA
merge()/omit()view over plain objects keeps a resolved table — every key mapped to the leaf that owns it — so a clientspreadrerunning its effect, orObject.keys/{...props}, is one lookup per key. Since the views became lazy (#3454) that table was built by the first per-key trap read as well. On the server that is the wrong trade: a component reads its merged props a few times, the element serializes them once, and the view is discarded, so a Kobalte-shaped component chain (Dialog.Trigger→Button.Root→Polymorphic) paid for a table per layer per element. Profiled underrenderToString, a third of the time was in the table code (mergeTable,tableSet,omitTable) and the garbage it produced, plusArray.prototype.concatcombining omit filters.- Signals: a per-key
get/has/getOwnPropertyDescriptoranswers by a source walk (last source first, oneineach) until the view has been read 16 times — the break-even between a build (~60 ns per key of every leaf) and a walk (~20 ns per source) — and then builds the table as before, so a long-lived client view read on every reactive rerun is one lookup per read from its first few updates on. Enumeration (ownKeys, or a consumer askingresolvedTable) builds it outright, unchanged. An omit over one object never builds one. The read count lives in the table slot until it is decided, so a view carries no extra field. Combined omit filters are copied withslice+pushinstead ofconcat(2–3× cheaper once optimized, and — unlike a hand-written loop — no more expensive than the builtin in the interpreter and baseline tiers, which is what an instruction-count benchmark under Valgrind mostly runs). - Web (SSR):
ssrElementno longer prefers a view's resolved table; it walks the view's entries the way it already walked the array form — an omit over a merge as its filtered leaf entries — so serializing an element builds no table at all. Attribute order is the same merged order (a key at the position of the last source that carries it).
Same-process A/B on the tier-1
polymorphic-chainSSR bench (200 rows,renderToString): the chain form goes from 8.2× the compiled floor to 5.8×, chain-static from 7.7× to 5.6× (−27% wall-clock). At the signals layer the props-chain bench improves 5–17% on build, 6–14% on build+consume, and steady-state per-key reads on a prebuilt view are unchanged (the table is in use). The only slower band is a view read 16–20 times and never enumerated (+4–17% at depth 1–3, faster at depth 7), which is the transition the threshold is designed around.Tests: the table is undecided after a handful of reads on both a merge and an omit-over-merge, built after the 16th with identical answers before and after; enumeration builds it on a fresh view; a plain omit never builds one; a store-leaf view settles to "none" and keeps walking. SSR: a spread over an omit-over-merge and over a bare merge serializes the merged order and builds no table.
- Signals: a per-key
-
Updated dependencies [8bf04ea]
-
Updated dependencies [d826cd3]
-
Updated dependencies [cc0396b]
-
Updated dependencies [53280e7]
-
Updated dependencies [3154ed6]
-
Updated dependencies [b298154]
-
Updated dependencies [899c2c4]
-
Updated dependencies [3ae9e92]
-
Updated dependencies [328580f]
-
Updated dependencies [61a114c]
-
Updated dependencies [0d8347a]
-
Updated dependencies [7623ce1]
-
Updated dependencies [56858e7]
-
Updated dependencies [af94f67]
-
Updated dependencies [e87d694]
-
Updated dependencies [5426ffb]
-
Updated dependencies [63560a1]
-
Updated dependencies [34287d8]
- solid-js@2.0.0-rc.9