You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
RFC 10 follow-up: support per-call server function cancellation #3057
RFC 10 rejects a per-call transport API partly because abort signals had no known consumer.
Router navigation provides a concrete consumer. A loader can start a server function call and then be superseded by another navigation. Routers already expose an AbortSignal for this lifecycle, but there is no way to attach that signal to the fetch performed by a server function reference. The global prepareRequest hook cannot represent invocation-specific cancellation safely.
Proposed scope
Add a narrow mechanism for passing an AbortSignal to one server function invocation. One possible shape is:
invokeServerFunction(fn,{ signal }, ...args);
The exact API is open. The mechanism should:
cancel the client transport request
work with GET and POST references
compose with rich arguments and single-flight responses
retain the existing plain callable API
avoid restoring arbitrary per-call headers or RequestInit policy
On HTTP dispatch, cancellation already has a natural server representation through event.request.signal. Direct SSR calls need a documented behavior but should not require an HTTP loopback.
Why core
Only the server-function transport can attach invocation-specific lifecycle state to its internal fetch. Routers can supply the signal, but cannot implement the transport mechanism outside core.
We took a hard look at this against solid-router's actual architecture, and the decision is not to add a per-call cancellation API. Reasoning, so the record is complete:
The stated consumer doesn't have a signal to hand us. Solid-router's supersession model is transition-based, not abort-based: a superseded navigation's in-flight results are discarded by latest-wins reactivity. There's no AbortController anywhere in the router — the loader-abort lifecycle in the proposal describes how other routers work, not this one.
Per-call is the wrong grain for the calls that matter anyway. The requests worth cancelling flow through query(), which dedupes and caches — an in-flight fetch isn't owned by any single caller, so one caller's signal must never kill the promise other subscribers share. If cancellation is ever bought here, it lands as refcounted teardown inside the router's cache layer (last subscriber leaving aborts), talking to the transport over an internal seam like the existing RPC one — not as a public per-invocation option. End users would only ever observe superseded requests disappearing, never hold a controller.
The expensive case already has its verb. Long-lived streams — the one place cancellation stops real ongoing work — are covered: liveQuery teardown returns the async iterator and the transport aborts the call. Cancellation rides the value protocol, scoped to the thing being cancelled.
What the discussion did settle, and what stays on record for the future: your issue prompted us to articulate the scope taxonomy behind RFC 10's surfaces — registration-scoped (server policy), declaration-scoped (call-path transport, withMeta/GET), and invocation-scoped (per-call identity: today args, and only a lifecycle signal if this door ever opens). A signal genuinely can't be declared or configured, so it passes the admission test the rejected per-call headers/RequestInit fail — the objection here is consumer and grain, not category. If someone brings a consumer whose calls are un-shared and measurements showing the waste matters, this reopens on that basis, through the internal-seam shape above. (One naming note for that future: .with is taken — it's partial application on actions.)
Thanks for pushing on this — it produced the sharpest articulation of these boundaries we've had, and directly shaped the rework direction on #3058 as well.
Reopening to keep this as the tracking point rather than a closed door — the analysis above stands (no public per-call API; the proposed shape is wrong-grained for deduped calls), but the underlying capability has a defined path to acceptance: a consumer with un-shared calls and measurements showing the waste matters, delivered through router-primitive-level refcounted cancellation over an internal transport seam, signal-only. Open until proven or obsoleted.
production build using the template's Node adapter
a Solid server function records event.request.signal and waits 2 seconds
two QueryObservers subscribe to the same query, producing one shared server-function request
the observers are released one at a time
With the current Solid transport:
after first release:
query signal aborted: false
server: pending
after last release:
query signal aborted: true
server: pending
after 2 seconds:
query signal aborted: true
server: completed (2000.77 ms)
As a control, I used prepareRequest to put that same query-owned signal on the internal fetch:
after first release:
query signal aborted: false
server: pending
after last release:
query signal aborted: true
server: aborted (294.93 ms)
after 2 seconds:
query signal aborted: true
server: still aborted
This confirms the ownership grain is correct. TanStack Query keeps the shared request alive while one observer remains and aborts only after the final observer releases it. Today that signal stops at the query boundary, so the Solid request and server work continue.
The prepareRequest wiring was only a probe. A mutable ambient signal is not a proposed API and would not compose safely with other transport configuration. The product shape can remain the one described above: a signal-only internal RPC/transport seam consumed by the TanStack query/router integration, with TanStack owning sharing and teardown. It does not require a public per-call RequestInit API.
This is now shipped on next (475744c), and it's worth recording how the posture changed, because the original close said "no per-call mechanism — revisit only with a concrete use case in hand." The use case you supplied (a data layer superseding a query needs the in-flight request cancelled) held up under scrutiny: it's a fact about one call, so no declaration (GET/withMeta) or session hook (prepareRequest) can express it. The call-scoped slot in RFC 10's lifetime taxonomy now has its tenant:
Shape.Function.prototype.call's silhouette — the options bag sits in the thisArg slot, the call's own arguments spread after it. The mental model we're documenting: declaration wrappers are bind (new reference, context travels with it), invoke is call (one application, no residue on the reference). A curried form was considered and rejected — invoke(fn, options) returning a callable is structurally a bind, and would have quietly revived withOptions.
Options are admission-tested, not open-ended. An option belongs here only if it varies between calls of the same function and cannot be declared or configured. Three pass today: signal (aborting rejects the call and cancels the request; timeouts compose via AbortSignal.timeout/AbortSignal.any), keepalive (unload-time calls), priority (fetch priority hint). Everything longer-lived is refused with a redirect to its home — invoke throws the pointer rather than silently dropping: headers → prepareRequest/withMeta/arguments (cache-key integrity), method → GET(fn) (CSRF posture), retries/dedupe → the data layer, wired through signal. This is the line that keeps the invocator from becoming the per-call RequestInit passthrough the RFC rejected — that rejection stands.
Composition. Dispatch rides a registered-symbol invocation channel (SERVER_FUNCTION_INVOKE) that's part of the reference contract, forwarded by wrappers the way declaration metadata already is. GET(fn) invokes over its query encoding (URL-length POST fallback included); live(fn) ends its iteration on abort, across reconnects. Data-layer wrappers (router query/action) adapt the channel to their own policy — a caching wrapper decides what a caller's abort means for a shared in-flight call — which is the follow-up PR on the router side.
Server mirror. In-process calls honor signal by rejecting the caller (the work runs to completion unless the function observes a signal of its own, same as a server behind HTTP); the transport hints are no-ops there, since they describe a wire that doesn't exist.
RFC 10 is amended accordingly (lifetime table, an invoke section with the admission test and redirect table, and the alternatives record updated to show the conditional cut resolving). Thanks for pressing on this one — the constraint that made it hard (transparent variadic signatures leave no room at the call site) is exactly what makes the rest of the design good, and the invocator is the honest price.
Activity
ryansolid commentedon Aug 25, 2026
We took a hard look at this against solid-router's actual architecture, and the decision is not to add a per-call cancellation API. Reasoning, so the record is complete:
The stated consumer doesn't have a signal to hand us. Solid-router's supersession model is transition-based, not abort-based: a superseded navigation's in-flight results are discarded by latest-wins reactivity. There's no AbortController anywhere in the router — the loader-abort lifecycle in the proposal describes how other routers work, not this one.
Per-call is the wrong grain for the calls that matter anyway. The requests worth cancelling flow through
query(), which dedupes and caches — an in-flight fetch isn't owned by any single caller, so one caller's signal must never kill the promise other subscribers share. If cancellation is ever bought here, it lands as refcounted teardown inside the router's cache layer (last subscriber leaving aborts), talking to the transport over an internal seam like the existing RPC one — not as a public per-invocation option. End users would only ever observe superseded requests disappearing, never hold a controller.The expensive case already has its verb. Long-lived streams — the one place cancellation stops real ongoing work — are covered:
liveQueryteardown returns the async iterator and the transport aborts the call. Cancellation rides the value protocol, scoped to the thing being cancelled.What the discussion did settle, and what stays on record for the future: your issue prompted us to articulate the scope taxonomy behind RFC 10's surfaces — registration-scoped (server policy), declaration-scoped (call-path transport,
withMeta/GET), and invocation-scoped (per-call identity: today args, and only a lifecycle signal if this door ever opens). A signal genuinely can't be declared or configured, so it passes the admission test the rejected per-call headers/RequestInit fail — the objection here is consumer and grain, not category. If someone brings a consumer whose calls are un-shared and measurements showing the waste matters, this reopens on that basis, through the internal-seam shape above. (One naming note for that future:.withis taken — it's partial application on actions.)Thanks for pushing on this — it produced the sharpest articulation of these boundaries we've had, and directly shaped the rework direction on #3058 as well.
ryansolid commentedon Aug 25, 2026
Reopening to keep this as the tracking point rather than a closed door — the analysis above stands (no public per-call API; the proposed shape is wrong-grained for deduped calls), but the underlying capability has a defined path to acceptance: a consumer with un-shared calls and measurements showing the waste matters, delivered through router-primitive-level refcounted cancellation over an internal transport seam, signal-only. Open until proven or obsoleted.
birkskyum commentedon Aug 25, 2026
I tested this against the current Solid 2 + TanStack template and found a concrete shared-call consumer.
Setup:
@solidjs/web@2.0.0-rc.2@solidjs/vite-plugin@3.0.0-next.33@tanstack/solid-query@6.0.0-rc.0/ query-core 5.101.0event.request.signaland waits 2 secondsWith the current Solid transport:
As a control, I used
prepareRequestto put that same query-owned signal on the internal fetch:This confirms the ownership grain is correct. TanStack Query keeps the shared request alive while one observer remains and aborts only after the final observer releases it. Today that signal stops at the query boundary, so the Solid request and server work continue.
The
prepareRequestwiring was only a probe. A mutable ambient signal is not a proposed API and would not compose safely with other transport configuration. The product shape can remain the one described above: a signal-only internal RPC/transport seam consumed by the TanStack query/router integration, with TanStack owning sharing and teardown. It does not require a public per-callRequestInitAPI.This is a synthetic 2-second workload rather than a production benchmark, but it gives us a repeatable end-to-end protocol reproduction. The current fullstack template already establishes TanStack Query as the owner of data calls: https://github.com/solidjs/templates/blob/main/solid-v2/fullstack-tanstack/src/lib/queries.ts
Add invoke — the per-call server function invocator (#3057)
ryansolid commentedon Aug 27, 2026
This is now shipped on
next(475744c), and it's worth recording how the posture changed, because the original close said "no per-call mechanism — revisit only with a concrete use case in hand." The use case you supplied (a data layer superseding a query needs the in-flight request cancelled) held up under scrutiny: it's a fact about one call, so no declaration (GET/withMeta) or session hook (prepareRequest) can express it. The call-scoped slot in RFC 10's lifetime taxonomy now has its tenant:Shape.
Function.prototype.call's silhouette — the options bag sits in thethisArgslot, the call's own arguments spread after it. The mental model we're documenting: declaration wrappers arebind(new reference, context travels with it),invokeiscall(one application, no residue on the reference). A curried form was considered and rejected —invoke(fn, options)returning a callable is structurally a bind, and would have quietly revivedwithOptions.Options are admission-tested, not open-ended. An option belongs here only if it varies between calls of the same function and cannot be declared or configured. Three pass today:
signal(aborting rejects the call and cancels the request; timeouts compose viaAbortSignal.timeout/AbortSignal.any),keepalive(unload-time calls),priority(fetch priority hint). Everything longer-lived is refused with a redirect to its home —invokethrows the pointer rather than silently dropping: headers →prepareRequest/withMeta/arguments (cache-key integrity), method →GET(fn)(CSRF posture), retries/dedupe → the data layer, wired throughsignal. This is the line that keeps the invocator from becoming the per-callRequestInitpassthrough the RFC rejected — that rejection stands.Composition. Dispatch rides a registered-symbol invocation channel (
SERVER_FUNCTION_INVOKE) that's part of the reference contract, forwarded by wrappers the way declaration metadata already is.GET(fn)invokes over its query encoding (URL-length POST fallback included);live(fn)ends its iteration on abort, across reconnects. Data-layer wrappers (routerquery/action) adapt the channel to their own policy — a caching wrapper decides what a caller's abort means for a shared in-flight call — which is the follow-up PR on the router side.Server mirror. In-process calls honor
signalby rejecting the caller (the work runs to completion unless the function observes a signal of its own, same as a server behind HTTP); the transport hints are no-ops there, since they describe a wire that doesn't exist.RFC 10 is amended accordingly (lifetime table, an
invokesection with the admission test and redirect table, and the alternatives record updated to show the conditional cut resolving). Thanks for pressing on this one — the constraint that made it hard (transparent variadic signatures leave no room at the call site) is exactly what makes the rest of the design good, and the invocator is the honest price.fix(patch-channel): second re-audit — adoption seams, exception safet…
invoke: forwarding the channel is opt-in for call-sharing wrappers
Stage 2: store patch channel + patch-mode list driver (dormant by def…