The pipeline
Error isolation
Blex refuses to let a misbehaving observer break dispatch. Everything it swallows is handed to you instead.
The problem
A dispatch fans out to middleware, the DevTools bridge, persistence, history and every subscriber. If any one of them throws and the exception escapes, the observers registered after it never run - persistence silently stops saving because an unrelated analytics handler had a bad day. Blex isolates each of those call sites and routes the failure to a single sink.
builder.Services.AddBitBlexServices(options =>
{
options.OnError = error => logger.LogWarning(
error.Exception,
"[blex:{Source}] {Detail}",
error.Source,
error.Detail);
});
Without a handler, isolated errors are written to Console.Error - visible in the
browser console, but easy to miss. Setting OnError is the first thing to do in
any real app.
BlexError
public readonly record struct BlexError(
string Source,
Exception Exception,
string? Detail = null);| Member | Description |
|---|---|
string Source |
Which part of the pipeline failed: subscriber, async reactor, middleware, persistence, restore, devtools, history, reset, register or prerender. |
Exception Exception |
The captured exception. |
string? Detail |
Context - usually the store name, storage key or qualified action name. |
What gets reported
| Source | Raised when |
|---|---|
subscriber | A handler passed to Subscribe / SubscribeTo / SubscribeToAction threw. Detail is the qualified action name. |
async reactor | A SubscribeAsync handler's task faulted. |
middleware | A middleware's BeforeAction or OnAction threw. The pipeline continues with the next middleware. |
persistence | Rehydrating, applying, serializing, scheduling or writing a persisted slice failed. Detail names the store or key. |
restore | A store's slice could not be deserialized during RestoreGlobalState. That store is skipped; the others still restore. |
devtools | A time-travel snapshot from the extension was malformed, a sanitizer threw, or the JS bridge failed to load or send. |
history | A BlexHistory.Changed handler threw while recording or after an external restore, or a history filter threw. |
reset | A store failed to reset during BlexManager.ResetAll(). That store is skipped; the rest still reset. |
register | Two stores were registered under the same name, so their state slices, DevTools entries and storage keys collide. |
prerender | Handing a store's state across the prerender-to-interactive boundary failed. The store simply renders from its own state instead - the worst case is rendering twice. |
This registers a subscriber that throws on the next counter/Increment. The action still applies, the other observers still run, and the failure lands in the log below - and in the Inspector's Errors tab.
Note that Count still went up. The mutation had already been applied when the
subscriber ran - isolation protects the pipeline, it does not roll anything back.
Guarantees
- The mutation is recorded even if a subscriber throws. A standalone
Set XwhoseStateChangedhandler throws is still recorded, because the state genuinely changed - skipping the record would leave persistence, history and DevTools out of sync with reality. - Sanitizers fail closed. If your DevTools state sanitizer throws, Blex
sends an empty tree rather than the unsanitized one, so a bug in redaction
cannot leak the data it was meant to hide. A failing action sanitizer sends
<sanitizer-error>. - Corrupt persisted data never blocks startup. An unreadable payload is reported, discarded and removed from storage; the app starts with defaults.
- The handler itself is guarded. If your
OnErrorthrows, that exception is swallowed - it is the last line of defence, and there is nowhere left to report to.
A realistic handler
builder.Services.AddBitBlexServices(options =>
{
options.OnError = error =>
{
// Persistence and restore failures are worth alerting on; a broken
// analytics subscriber usually is not.
var level = error.Source is "persistence" or "restore"
? LogLevel.Error
: LogLevel.Warning;
logger.Log(level, error.Exception,
"Blex {Source} failed ({Detail})", error.Source, error.Detail);
};
});OnError is captured on the singleton options
BlexOptions is registered as a singleton, and the delegate you assign is
copied onto each scoped BlexManager as it is created. Do not close over a
scoped service in it - under Blazor Server that would capture one circuit's dependency
and share it with all of them. Close over something process-wide (an
ILoggerFactory, a metrics client), or assign
manager.OnError per scope instead.
Setting it per scope
OnError is also a settable property on BlexManager, so a scoped
component or service can install its own handler where a scoped dependency is genuinely
needed.
[Inject] private BlexManager Manager { get; set; } = default!;
[Inject] private IToastService Toasts { get; set; } = default!; // scoped
protected override void OnInitialized()
=> Manager.OnError = error => Toasts.ShowWarning($"{error.Source}: {error.Exception.Message}");Errors inside an action body are not isolated
This is about the observers of an action. An exception thrown by your own
[BlexAction] method body propagates to the caller as usual - only
[BlexEffect] bodies capture their exception, into the generated
XxxError property.