Suspension
A pending read stops the current computation and records what it is waiting on. When that source settles, the computation starts again. Boundaries supply display values while it waits.
The problem
A memo that reads an async value has to do something before the value exists:
let displayName (u: User) = u.FirstName + " " + u.LastName // an ordinary helper
let greeting = createMemo (fun _ -> "Hi " + displayName (user.Value))
displayName knows nothing about reactivity. If user is still loading, the read has to stop the memo body, including frames like displayName that sit between the read and the memo, and the graph has to know to run the memo again when user settles.
The pending channel
Implemented.
Every node reports Status flags for readiness, failure and stale work:
| Flag | Meaning |
|---|---|
Status.None | No flag is set: the node holds a settled value and has no pending re-run. |
Status.Pending | The node is waiting on a value that has not arrived. |
Status.Error | The node's last run failed. |
Status.Uninitialized | The node has not produced a value yet. |
Status.Stale | The graph has marked the computation out of date since its last run. |
Pending and dirty are independent. A dirty node has an out-of-date value and recomputes on its next read. A pending node has already run and is waiting on a source. A node can be both.
Status.Stale exposes the dirty state. Computation nodes report it (memos, effects, async memos, boundaries, projections, stream memos, timed values and bindings) from the moment the graph marks them until they re-run or confirm their value. Reading the flag runs nothing and records no dependency. A memo is checked against its sources when it is read, so it can report None after a source changes; an effect woken inside a batch reports Stale until the batch ends. Test a single flag with HasFlag, since Status.None excludes a stale node.
Two mechanisms work together. Each one needs the other:
- Node status lives on the source and persists. It records that a flight is in progress, and it propagates. A memo, effect or boundary that reads a pending node becomes pending too.
- A thrown
NotReadyExceptionis raised by the read and is caught immediately. It stops the reading computation's body.
Status without the throw would require every consumer to check a flag by hand. The throw without status would leave the graph with no record of when to retry.
What a pending read does
When a body reads .Value on a pending node, the read does the following, in this order:
- It links the dependency edge.
- It throws
NotReadyException, which carries the source.
The engine catches the exception around the body. It adds the source to the computation's pending sources, sets the computation's status to Pending, and publishes no value. When the source settles, the linked edge wakes the computation, and the body runs again from the top.
The order is required. If the read threw before linking the edge, the computation would never be woken and would stay pending indefinitely.
Pending sources are kept as a set. A later write can reach a computation through a source that has already settled while another source is still in flight. In that case the computation stays Pending, and the unsettled source remains in its set. A settled branch that changes does not make a join readable while another branch is still in flight:
let a = createAsyncSource<int> ()
let b = createSignal 1
let c = createMemo (fun _ -> a.Value + b.Value)
c.TryValue // Pending
b.Value <- 2
c.TryValue // still Pending, and no value was published
a.Settle 10
c.TryValue // Ready 12
In the map, a write to b while a is pending re-runs c, which stays pending and publishes nothing. Settling a lets c publish.
Example code
let a = createAsyncSource<int> ()
let b = createSignal 1
let c = createMemo (fun _ -> a.Value + b.Value)
createEffect (fun () -> printfn "c = %d" c.Value)
controls [
button "Write b" (fun () -> b.Value <- b.Value + 1)
button "Settle a" (fun () -> a.Settle 10)
]
Three ways to read
.Valueis the transparent read. It returns the value, raisesNotReadyExceptionwhile the node is Pending, and raises the recorded error while it is Failed. It works from inside any helper.TryValuereturns aReading<'T>, which is one ofReady value,PendingorFailed error. It links the edge exactly as.Valuedoes, so aPendingresult still wakes the reader when the source settles.Peekis an untracked read of the last settled value. It never suspends. On an async memo it returns the value held from before the current flight. These docs call that value the retained value.
The engine traverses the graph through the non-throwing path. User code reading a pending .Value raises NotReadyException, including a top-level read outside a computation.
A body that catches NotReadyException in its own try ... with is still Pending, whatever it returns. The graph records the pending read itself, not only the exception. The one exception is a read inside untrack: there, a caught read lets the body's result stand. To substitute a value while something is pending, use a boundary, or TryValue.
Why a throw
Considered and rejected alternatives.
A throw is expensive on .NET compared with a return. Four other mechanisms were evaluated before the throw was kept.
A result type on every read. Reading<'T> with a computation expression that stops at Pending is sound, does not allocate, and compiles under Fable. However, every helper between the read and the memo would then have to return Reading<_> as well. Ranvier's pending channel avoids this call-site colouring, inspired by Solid 2's release-candidate async design. Reading<'T> ships as the result of TryValue, for code that wants to handle Pending explicitly, but it is not the default read.
F# resumable code. Resumable state machines (the machinery behind task { }) could in principle pause a body and resume it instead of re-running it. In practice, three results rule them out:
- A resumption point has to be visible to the compiler after inlining. A pending read inside a non-
inlinehelper does not compile (error FS3501). That makes resumable code a faster implementation of the result-type approach, not transparent suspension. - Fable 5.13 does not compile resumable code. A test builder produced seven distinct errors. Fable's own
task { }support mapstaskto promises by name and does not support resumable code in general. - Resuming breaks consistency (see Re-running versus resuming).
A message queue per node. A MailboxProcessor round trip took about 4.9 µs on .NET 10, which is more than a throw. It also cannot settle synchronously. A queue belongs at the edge of the graph, where completions from other threads arrive. It does not belong between nodes. See The async graph on .NET.
A marker value inside 'T. A read that returns 'T has no spare value to mean "pending", because Unchecked.defaultof<'T> is a legitimate value for every type. Changing the return type to carry the marker is the result-type approach again.
What a throw costs
On one machine (.NET 10, Release build, no debugger), throwing through ten frames took about 3 µs. A sentinel return through the same depth took about 41 ns. Entering a try ... with block without throwing added about 4 ns over a plain call. Throw cost grows roughly linearly with stack depth. Throws were about 30% cheaper on .NET 10 and 11 than on .NET 9.
Reusing a cached exception object does not help on .NET. Fresh throws measured slightly cheaper than cached ones on .NET 9, 10 and 11, so NotReadyException is allocated fresh for each throw. JavaScript can use thrown marker objects without an Error stack. Ranvier's .NET implementation has no supported way to disable runtime stack capture for this exception; overriding the rendered StackTrace does not avoid the capture cost.
The number of throws depends on how many transparent reads hit pending sources, not on the size of the graph. The engine's own traversals, TryValue, and boundary checks do not throw. In a page-sized graph where a handful of reads are waiting on data, that comes to a handful of throws per settle.
A variant that was removed
Considered and rejected. One variant, the tolerant read, returned a default value from a pending read instead of throwing. The body was allowed to finish, and its result was discarded. The variant shipped briefly as an option and was then removed. Its savings applied only to value types. For reference types, the default value is null, and the resulting NullReferenceException cost more than the throw it replaced. The variant also ran side effects and loops on placeholder data. Code that wants to avoid the throw on a hot path can use TryValue.
Re-running versus resuming
Implemented. The alternative was tested and rejected.
A body re-runs from the start after it suspends. It never resumes from the point where it stopped. Resuming can produce a result that corresponds to no state the graph was ever in.
Take a memo c = s + a, where a is an async value computed from s:
| Step | Event | Re-run from the top | Naive resume |
|---|---|---|---|
| 1 | s = 1. c reads s = 1, then suspends on a. | Pending | Pending, holding s = 1 |
| 2 | s is written to 2. A new flight for a starts. | Pending, no value published | Pending, still holding s = 1 |
| 3 | a settles with 20, the result for s = 2. | c re-reads both values: 22 | c adds the held 1 to 20: 21 |
21 combines the old s with the new a. Conditional bodies have the same problem: a condition may change while its selected branch is pending. Re-running reads both the condition and its inputs again. A correct resume mechanism would need to validate earlier reads and restart when they changed.
Boundaries
Implemented.
Pending and error both propagate outward. Without something to stop them, one request in flight would put every downstream node into the pending state. A boundary is a computation that stops a channel coming out of its body. In place of the channel, it returns a value of the same type, so its readers see an ordinary value.
A boundary is control flow over the graph, not rendering. What the substituted value represents is up to the caller: a view, a message or a number.
| Constructor | Body pending | Body failed |
|---|---|---|
createSuspense fallback body | Shows fallback last | Passes through as Failed |
createErrorBoundary recover body | Passes through as Pending | Shows recover ex last |
createBoundary fallback recover body | Shows fallback last | Shows recover ex last |
last is the boundary's last value, ValueNone before its first. Returning it keeps the last value shown while the body reloads.
let data = createAsyncSource<string> ()
let view = createSuspense (fun _ -> "loading") (fun () -> "loaded " + data.Value)
view.TryValue, view.IsWaiting // Ready "loading", true
data.Settle "report"
view.TryValue, view.IsWaiting // Ready "loaded report", false
The suspended read inside the body linked its edge before it threw. When the source settles, the boundary is marked dirty and its readers are notified. On the next read, the body runs again from the top.
All three constructors return a Boundary<'T>:
IsWaitingis true while the fallback stands in for the body. A caught channel is invisible to the boundary's readers by design, so this is the only way to tell a fallback from a real value.Caughtholds the exception thatrecoverhandled on the current run, ornull.CaughtFromholds the node it originated in.IsWaiting,CaughtandCaughtFromare tracked reads, so an effect that reads onlyIsWaitingstill wakes when the body settles.
Boundary rules:
- A boundary catches what its body reads, directly or through memos. A node that the body creates but does not read is outside the boundary's reach.
- A fallback can read reactive values. A fallback that suspends leaves the boundary Pending. A fallback that throws leaves it Failed.
- A
recoverthat re-raises leaves the boundary Failed. This narrows the boundary to the errors thatrecoverhandles. - A boundary owns the nodes its body creates and replaces them on every re-run. Create an async value outside the boundary and read it inside. An async value created and read inside the body starts a new flight on every settle, and the boundary shows its fallback indefinitely.
Errors and recovery
Implemented.
A failure is a settled outcome, not a slow success. When a source fails, a memo that reads it becomes Failed, and its readers see the error, not Pending. The exception passes through intermediate memos unchanged.
let price = createAsyncSource<int> ()
let shown = createMemo (fun _ -> price.Value)
price.Fail (exn "offline")
shown.TryValue // Failed "offline"
price.Settle 12
shown.TryValue // Ready 12
In the map, Fail marks price, shown and the effect failed. Settle 12 clears the error at every level.
Example code
let price = createAsyncSource<int> ()
let shown = createMemo (fun _ -> price.Value)
createEffect (fun () -> printfn "shown %d" shown.Value)
let offline = exn "offline"
controls [
button "Fail" (fun () -> price.Fail offline)
button "Settle 12" (fun () -> price.Settle 12)
]
A failed source recovers at its next Settle. A failed computation re-runs when a tracked input changes; a successful run clears the error. Re-reading an unchanged failed node serves its stored failure. An error boundary shows recover ex last until its body succeeds.
A throwing effect records its Status and Error; later effects in the flush still run.
Contracts lists where an exception from each kind of callback lands.
State labels
The examples and signal maps use these state labels:
| Label | What the reader sees |
|---|---|
| Ready | A settled value. TryValue returns Ready. |
| Pending | No usable value yet. TryValue returns Pending. |
| Retained value | Pending, but Peek still returns the last settled value. |
| Fallback | A boundary is showing its fallback. IsWaiting is true. |
| Failed | TryValue returns Failed. |
| Recovered | A boundary is showing recover ex last. Caught holds the error. |
Open questions
Exploratory.
- Real-world throw counts. The estimate that a settle involves a handful of throws has not been measured in a real application.
- Additional async features. Transitions and optimistic lanes remain outside the implemented pending-channel model. Ranvier does not promise behavioral parity with Solid.