Blex for Blazor

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.

C#
Program.cs
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

C#
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

SourceRaised when
subscriberA handler passed to Subscribe / SubscribeTo / SubscribeToAction threw. Detail is the qualified action name.
async reactorA SubscribeAsync handler's task faulted.
middlewareA middleware's BeforeAction or OnAction threw. The pipeline continues with the next middleware.
persistenceRehydrating, applying, serializing, scheduling or writing a persisted slice failed. Detail names the store or key.
restoreA store's slice could not be deserialized during RestoreGlobalState. That store is skipped; the others still restore.
devtoolsA time-travel snapshot from the extension was malformed, a sanitizer threw, or the JS bridge failed to load or send.
historyA BlexHistory.Changed handler threw while recording or after an external restore, or a history filter threw.
resetA store failed to reset during BlexManager.ResetAll(). That store is skipped; the rest still reset.
registerTwo stores were registered under the same name, so their state slices, DevTools entries and storage keys collide.
prerenderHanding 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.
Live demo Break a subscriber on purpose

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.

Count 0
Errors captured 0

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 X whose StateChanged handler 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 OnError throws, that exception is swallowed - it is the last line of defence, and there is nowhere left to report to.

A realistic handler

C#
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.

C#
[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.

An unhandled error has occurred. Reload ๐Ÿ—™