Blex for Blazor

State lifecycle

DevTools & time travel

Every action streams into the Redux DevTools extension with its payload and the resulting state tree - and the slider rewinds your app.

Getting connected

  1. Install the Redux DevTools browser extension.
  2. Run the app with <BlexProvider> in place and EnableDevTools left at its default.
  3. Open the browser devtools and switch to the Redux tab. The instance is named after options.DevToolsName.
  4. Interact with the app. Every action arrives with its argument payload and the state that followed it.
  5. Drag the slider, or use the jump buttons, to rewind the application live.
C#
Program.cs
builder.Services.AddBitBlexServices(options =>
{
    options.DevToolsName = "My App";   // shown in the extension's instance dropdown
});

This very page is connected

If you have the extension installed, open it now and look for the instance named Blex Docs. Then press the buttons below and jump around the timeline - the demos on this site will rewind with it.

Live demo Generate some history

Dispatch a few actions, then rewind them from the extension's timeline.

Count 0
Step 1
settings.Token (real value) sk-live-51H8xQ2eZvKYlo

In the extension, that token reads <redacted> - this site registers options.RedactDevToolsKeys("Token").

What travels over the bridge

On connect, the manager sends the current tree as the initial state. After that, each recorded action sends an action object and the resulting tree:

What the extension receives
// init, on connect
{ "counter": { "Count": 0, "Step": 1 }, "settings": { "Accent": "violet" } }

// send, per action
action: { "type": "counter/SetStep", "payload": { "step": 5 } }
state:  { "counter": { "Count": 0, "Step": 5 }, "settings": { "Accent": "violet" } }

The state tree is keyed by store name - counter, settings and so on - which is exactly what BlexManager.CaptureGlobalState() produces.

Messages Blex handles

Time-travel is driven by the extension sending messages back. HandleDevToolsMessage interprets these:

MessageEffect
JUMP_TO_STATE / JUMP_TO_ACTIONApplies the state the extension supplies - the slider and the jump buttons.
ROLLBACKReverts to the last committed snapshot, or to the state supplied with the message when nothing has been committed.
COMMITTakes the current state as the new baseline and re-initializes the monitor.
RESETRestores the state captured when DevTools first connected, then re-initializes the monitor.
IMPORT_STATEApplies the last computed state from an imported session.
PAUSE_RECORDINGStops streaming actions to the monitor. The app keeps running: actions still apply, and still reach middleware and subscribers.
LOCK_CHANGESFreezes the app - every action is vetoed until it is unlocked. Time-travel restores keep working, which is the point.

Applying a snapshot goes through RestoreGlobalState, so it never records new actions - the timeline you are scrubbing does not grow while you scrub it. A store whose slice fails to deserialize is reported to OnError and skipped; the rest still restore.

Pause and lock

The monitor's two recording controls are different in kind, and the difference matters when you are hunting a bug. Pause recording is about the timeline: the app carries on exactly as before, it just stops filling the monitor with noise while you set up the state you actually want to inspect. Lock changes is about the app: every action is refused, so nothing can move underneath you while you scrub.

Both are readable from the manager, so the UI can tell the user why its buttons appear dead:

C#
// Paused: actions apply and are observed, they are just not streamed to the monitor.
if (manager.IsDevToolsPaused)
    _hint = "DevTools recording is paused.";

// Locked: every action is vetoed, exactly as a UseFilter returning false would.
// Restores are deliberately not gated, so the slider still moves.
if (manager.AreChangesLocked)
    _hint = "The monitor has locked changes - unlock it to interact.";
Live demo What the monitor's buttons do here

Open the Redux DevTools panel and press Pause recording or Lock changes, then use the counter. This card reads the manager's own flags.

counter/Count 0
IsDevToolsPaused False
AreChangesLocked False

Paused, the counter still moves but the monitor stays quiet. Locked, the counter stops moving - and the slider still works.

Disconnecting always unlocks

Closing the monitor, or tearing down a circuit, while "Lock changes" was on would otherwise leave the application permanently frozen with no remaining way to unfreeze it. Disconnecting the bridge clears both flags.

Redacting sensitive state

The state tree is handed to a browser extension. Anything in it is visible to that extension. Blex gives you two hooks plus a shorthand.

C#
Program.cs
builder.Services.AddBitBlexServices(options =>
{
    // Take full control of the state tree...
    options.DevToolsStateSanitizer = state =>
    {
        var clone = (JsonObject)state.DeepClone();
        if (clone["user"] is JsonObject user)
            user["email"] = "<hidden>";
        return clone;
    };

    // ...and redact these keys anywhere in the tree (and in action payloads) on top of it.
    // Register redaction last: it wraps the sanitizer above, but an assignment after this
    // call would replace it.
    options.RedactDevToolsKeys("token", "password", "ssn");

    // The action label has its own hook.
    options.DevToolsActionSanitizer = label => label.Replace(_userId, "<id>");
});
Member Description
options.DevToolsName The instance name shown in the extension's dropdown. Default "Blex".
options.RedactDevToolsKeys(params string[]) Shorthand: replaces the named keys, anywhere in the tree, with "<redacted>". Applies to action payloads too.
options.DevToolsStateSanitizer Func<JsonObject, JsonObject>. Full control over the tree before it is sent. Display only.
options.DevToolsActionSanitizer Func<string, string>. Rewrites the action label - useful for stripping ids out of names.

RedactDevToolsKeys walks the whole tree - objects and arrays - replacing matching keys with <redacted>, and it is applied to action payloads as well as state, so an argument named password is redacted too.

It is additive, and it composes. Calling it a second time widens the redaction set rather than replacing it, so a library and an app can each register their own secrets without one silently unmasking the other's. A custom DevToolsStateSanitizer assigned before the call still runs - redaction is applied on top of its output - which lets you reshape the tree and redact it in the same pipeline.

Additive and composing
// A shared setup module redacts what it knows about...
options.RedactDevToolsKeys("token");

// ...and the app adds its own. The set widens; "token" stays redacted.
options.RedactDevToolsKeys("ssn", "dateOfBirth");

Assigning the sanitizer last wins

The composition is one-directional: redaction wraps whatever sanitizer was already there. Assigning DevToolsStateSanitizer after calling RedactDevToolsKeys replaces redaction outright. Register redaction last.

Redaction and time-travel fight each other

DevTools echoes the state it was shown back to the app when you jump. A redacted value therefore comes back as the literal string <redacted> and overwrites the real one. Redact only fields you never need to jump back through - or turn DevTools off entirely in the environments where the data is real.

Sanitizers fail closed

If your state sanitizer throws, Blex sends an empty tree rather than the unsanitized one - a bug in redaction cannot leak what it was meant to hide. A throwing action sanitizer yields <sanitizer-error>. Both are reported through OnError with source devtools.

Turning it off

For production, disable the connection outright. Nothing is serialized and nothing is sent.

Razor
<BlexProvider EnableDevTools="@Env.IsDevelopment()">
    <Router ... />
</BlexProvider>

Under the hood

The core package has no JS dependency - it only knows the IBlexDevTools interface. Bit.Blex.Blazor supplies BlexReduxDevToolsConnector, which loads a small ES module and talks to window.__REDUX_DEVTOOLS_EXTENSION__.

C#
public interface IBlexDevTools
{
    void Init(JsonObject globalState);
    void Send(string actionName, JsonObject globalState);
    void Send(string actionName, JsonObject globalState, JsonObject? payload)
        => Send(actionName, globalState);
}
  • If the extension is not installed, connect returns false and everything becomes a no-op - no errors, no cost.
  • Interop calls are dispatched without blocking the dispatch pipeline, but their tasks are still observed so a transient failure cannot surface as an unobserved exception.
  • Disconnecting detaches only Blex's own subscription, rather than killing every DevTools instance on the page.
  • The bridge advertises exactly what it implements: jump and export yes; dispatch, skip and reorder no.

Connecting a different sink

IBlexDevTools is public, so the manager can feed anything - a server-side session recorder, a test double, a custom monitor.

C#
public sealed class RecordingDevTools : IBlexDevTools
{
    public List<(string Action, JsonObject State)> Frames { get; } = [];

    public void Init(JsonObject state) => Frames.Add(("@@INIT", state));

    public void Send(string action, JsonObject state) => Frames.Add((action, state));
}

manager.ConnectDevTools(new RecordingDevTools());
// ... and later
manager.DisconnectDevTools();
Stores in this app's tree 9
Names counter, cart, equalityDemo, effectLab, search, contacts, settings, orders, notifications
An unhandled error has occurred. Reload ๐Ÿ—™