Undo, Redo, and Live Preview - No Diffs Needed

By Jeffrey Needles

July 14, 2026
Engineering
Blazor
Templates

Undo, Redo, and Live Preview - No Diffs Needed

The HCTI template editor stores a typed model of canvas settings, blocks and nested values. That same model is saved and later rendered on our servers, so editor mutations cannot be treated as disposable UI state.

Undo and redo sound simple until one gesture changes several properties across several selected blocks. Live preview adds another problem: the browser needs to show a value immediately without turning every pointer movement or key repeat into permanent history.

Those are related requirements, but they do not have the same lifetime. History needs to preserve the final intent of an edit. Preview state may exist for a few milliseconds and should disappear without leaving anything behind. Treating both as ordinary model mutations would either fill the history stack with noise or force every control to invent its own workaround.

C# does not give generic editing code a cheap, type-safe way to reach an arbitrary nested property. The common options involve reflection, expression trees, strings or a large amount of handwritten routing. Reflection is flexible, but it moves mistakes to runtime and adds work to a path that can run constantly while somebody is using the editor. In languages like JavaScript or Ruby, code can simply dig into objects by property name. With the help of Roslyn source generators, we have been able to bring that flexibility into C# while keeping the generated routing typed and avoiding reflection at runtime.

We handle both through a generated change-tracking layer built around compact property addresses, typed model facades and explicit transactions. Preview values travel through almost the same paths, but remain separate from committed template state.

The generators mentioned here are Roslyn source generators. They run while the C# project builds and make more ordinary C#, so the browser does not generate routing code or use reflection to discover it at runtime.

See how Blazor controls feed this tracking layer, how preview values become CSS, or use the architecture overview for a wider map of the editor.

Change Tracking

.NET has plenty of established change-notification patterns, from MVVM and INotifyPropertyChanged to libraries like R3. Blazor already handled much of the ordinary UI rendering. What we needed was a mutation path tuned to this model, with reliable undo/redo and enough information to update the right downstream systems. Generating that editing layer from the model was a comfortable choice.

Inside the editor, most UI code does not speak to a raw TemplateBlockBase child. Everything is fronted by a Tracking_ implementation, with another generated facade for each nested type.

The generated code calls the wrapped value _shadow, which may make it sound like a second copy. It is not. _shadow is the actual serializable model object behind that facade. A generated update method reads the old value, writes the new value to that object and records the mutation with the change tracker. The facade adds a controlled editing surface around the model rather than maintaining another model that has to be synchronized later.

That separation has a few practical effects:

  1. The UI can work with a model that is intentionally shaped for editing.
  2. The saved template model does not need editor-only setters or history state.
  3. Mutations can flow through one place, where they can be recorded, validated, reverted and replayed.

Path Addressing

A scalar change is easy to describe: one property moved from 12px to 16px. For that case, store a node ID, a path, the old value and the new value. The editor also has block additions, removals, list inserts, list reorders, dictionary changes, grid track edits, generated values and preview-only state. Force all of that into one old/new shape and you end up lying to yourself pretty quickly.

The common part is not "old value and new value." The common part is:

public interface ITrackedChange
{
    uint NodeId { get; }
    ChangedPathAddress Address { get; }
    ChangeCause Cause { get; }

    bool TryCoalesce(ITrackedChange next, out ITrackedChange merged);
    ITrackedChange CreateReplayChange(bool isUndo);

    void ApplyUndo(ITrackedNodeResolver resolver, IChangeTrackingService tracker);
    void ApplyRedo(ITrackedNodeResolver resolver, IChangeTrackingService tracker);
}

There are specific change types behind that. TrackedChange<T> is the normal scalar case. TrackedListItemChange<T> handles list mutations. Block adds and removes are their own things. Each type knows how to coalesce, undo and redo itself.

The ChangedPathAddress is the part I find most interesting. It answers the question: "where, inside this tracked node, is the change meant to land?"

Was it the X position of block 7? The second color stop in a gradient? The fallback value of a templatable image URL? The column-gap of a panel that only exists when the panel is in grid mode?

That is where the design can get brittle very quickly. There are a few obvious options:

  • Use strings like blocks[7].background.gradient.stops[1].color.
  • Use expressions like x => x.BackgroundFill.Gradient.Stops[1].Color.
  • Use an explicit path address generated from the model.

Strings are tempting because they are easy to serialize and debug. They're also very easy to mistype, and refactors do not help you. Expressions are tempting because they are type checked, but they are awkward once you need to serialize, compare, group and replay changes. They are also not a great fit once you are crossing the boundary between generated facades, nested values, collections and UI components that do not know much about the concrete block they are editing.

So the editor uses generated path addresses.

At runtime, the address is deliberately compact. The generator creates one ChangedPathId enum from the distinct property names across the whole template model graph. Then it calculates the deepest possible path and emits ChangedPathAddress around an InlineArray of that size. Right now, that means an inline array of 10 PathKeyOrIdx items, plus a Depth byte.

So an address is basically: "here are up to N generated path ids, and maybe an index/key when the path walks through a collection." No strings, no reflection, no expression parsing at runtime.

new ChangedPathAddress(
    ChangedPathId.BackgroundFill,
    ChangedPathId.Gradient,
    ChangedPathId.Stops,
    new PathKeyOrIdx(ChangedPathId.__ZIDX__, 1),
    ChangedPathId.Color);

In practice, most callers do not write that out. The source generator emits constant paths like ConstantChangedPaths.block__ImageBlockData.Media_SourceUrl_TemplateKey, and the property descriptors carry those paths into the UI.

Generic inputs can stay generic. A color picker does not need to know whether it is editing text color, border color, a gradient stop or a shadow. A size input does not need to know whether it is editing width, margin, grid gap or font size. The sidebar gives the input a value and a path. On preview it calls ApplyTrackedPreviewValue(...); on commit it calls ApplyTrackedValue(...).

The generated tracking wrapper then routes that address through pattern-matched apply methods. If the path is Position.X, it goes to the position proxy. If the path is Content.TemplateKey, it goes to the templatable string wrapper. If the path points into a list, the generated list wrapper handles the index segment.

Visual editors inevitably drift toward untyped boundaries: generic inputs, dynamic sidebars, JavaScript interaction events, collection indices, template variables and drag frames. Those callers can say "set this path to this value." Once the call is back in C#, generated code routes it through typed update methods again. Property panels, preview, undo/redo, validation, variable sync and CSS cache invalidation can all use the same address without string parsing or reflection-heavy lookups.

Transactions

The second piece is that the transaction, not the individual property, is the unit of user intent.

A resize is a good example. Depending on the block and parent layout, committing a resize might update position, width mode, height mode, width value and height value. It might also need to normalize sizing into pixels or percentages. That should be one undo step, because to the user it was one resize.

In code, that looks roughly like:

using var tx = ChangeTracker.BeginTransaction();

block.Position.UpdateX(ChangeTracker, nextX);
block.Position.UpdateY(ChangeTracker, nextY);
block.Dimensions.Width.UpdateMode(ChangeTracker, DimensionMode.Defined);
block.Dimensions.Width.UpdateValue(ChangeTracker, nextWidth);
block.Dimensions.Height.UpdateValue(ChangeTracker, nextHeight);

BeginTransaction() returns a scope, and leaving the using block commits it. The scope can also be explicitly reverted if the operation needs to be abandoned. Each generated Update... method mutates its wrapped model value and records a change with the active transaction. Compatible changes are coalesced inside that transaction. When it commits, the change tracking service validates the changed nodes and paths, publishes validation issues, reverts the transaction if there are blocking errors, and only then dispatches committed handlers.

Validation happens before committed handlers run. A bad parent cycle or impossible grid state should not leak into history and downstream editor state, so the same mutation path is responsible for recording, validating and reverting.

The committed handlers then do the editor-specific follow-up work: refresh hierarchy indexes, update selection/layers, resync discovered template variables when a template key or requirement mode changed, notify preview CSS, and mark the interaction runtime scene dirty.

Callbacks

Committed transactions also give us one place to keep derived state up to date. Services register callbacks with the change tracker, inspect the paths in the completed transaction, and refresh only the state affected by those changes. They do not need to be wired into every input that might produce the change.

The "Recent Color" swatches that appear in the color picker are a great example. TemplateColor is marked once with a change-event helper:

[TrackerChangeEventHelper("ColorChanged")]
public partial class TemplateColor
{
    // Parsing, formatting and color channel helpers...
}

The source generator finds every property path that can lead to a TemplateColor and produces an Event_ColorChanged helper for ChangedPathAddress. That includes direct foreground colors, but also colors nested inside gradients, patterns, borders, shadows, etc. The generated matcher is long, so the code using it can stay short.

EditorHost registers one method when the component initializes. The tracker returns an IDisposable, which we hold onto and dispose with the component. The order is useful when one committed callback depends on state prepared by an earlier one.

// Shortened for readability.

public partial class EditorHost : IAsyncDisposable
{
    private IDisposable? changeTrackerCommittedRegistration;

    protected override async Task OnInitializedAsync()
    {
        await base.OnInitializedAsync();

        changeTrackerCommittedRegistration =
            ChangeTracker.RegisterCommittedHandler(
                HandleTransactionCommitted,
                // Leave room for callbacks to run before or after EditorHost.
                order: 200);
    }

    private void HandleTransactionCommitted(
        ChangeTransactionCommitted transaction)
    {
        var shouldSyncColors = false;

        foreach (var change in transaction.AsSpan())
        {
            if (change.Address.Event_ColorChanged)
            {
                shouldSyncColors = true;
            }
            // other checks....
        }

        if (shouldSyncColors)
        {
            SyncColorServiceUsage();
        }
    }

    public ValueTask DisposeAsync()
    {
        changeTrackerCommittedRegistration?.Dispose();
        return DisposeEditorHostAsync();
    }
}

Adding another TemplateColor property automatically adds its paths to that helper. The swatch service does not need to know which input changed it or where the new property lives in the model.

The same callback flow powers the variable previewer, grid occupancy cache, font tracking and several other pieces of derived editor state. Some use generated event helpers, while others match a small set of generated paths directly. Either way, the wiring happens after a transaction commits instead of being repeated across the controls that initiated it.

Undo/redo is built on top of those committed transaction batches. The history service stores the batch, not a single property. Undo replays the changes backward through ApplyUndo, redo replays them forward through ApplyRedo. The replay still routes through tracked nodes rather than poking raw DTOs, which keeps the normal side effects aligned.

Why Not Just Diff the Model?

Another tempting path is to let anything mutate the model, then diff the whole template afterward. That can work for simple forms, but it felt wrong here.

Templates can get large. Some edits are frequent. Some values are nested. Some values have validation rules. Some changes are editor-only and should never be saved. Some changes should be grouped into a single undo operation. Some should not land in history at all.

There is a smaller version of the same idea where you snapshot the whole block before an edit, let the UI mutate it, and then compare or store the block afterward. That is simpler than diffing the entire template, but it still makes every edit block-sized. Change one nested color stop and the system sees "the block changed." Validation, side effects, history grouping and preview cleanup all have to rediscover intent after the fact.

Diffing the whole thing, or even the whole block, throws away too much intent. By the time you discover that the model is different, you've already lost the context of how and why it changed.

A path-addressed transaction keeps that intent closer to the user action. The model is still plain enough to serialize and render, but the editing layer has a richer vocabulary for mutation.

Preview Versus Commit

Dragging a block, scrubbing over a color picker or nudging with the keyboard can produce dozens or hundreds of intermediate values. The user should see them immediately, but the normal tracked value should not change until the interaction is finished. Otherwise undo would be technically correct and completely miserable.

Properties become previewable through explicit metadata or their type metadata. TemplateColor opts in once, so we do not need to remember to mark every color property separately.

The generator adds matching Preview... properties to the tracked proxies and underlying partial classes. They are ignored during serialization and take precedence when rendering CSS. A committed update clears them, because once a value is real there should not be stale temporary state hanging around.

The generated code is long, but there are only four things to watch for:

  • PreviewColor and DisplayColor are a pair. The saved model still has Color. The preview field sits beside it, and generated CSS reads the display value. Preview is not a second model. It is a temporary overlay on top of the same typed value.
  • The preview setter increments the parent's preview version. The stylesheet components use that version to decide whether they need to rebuild preview CSS for the block. A color picker can move quickly without forcing the editor to rediscover what changed from scratch.
  • The routing methods are where the UI gets back into typed C#. An input can carry an address and a value without knowing whether it is editing a block background, canvas background, grid track, gradient stop or pattern color. The generator emits typed color, boolean, number, size and enum overloads, which cuts down on boxing and allocations compared with the older generic version.
  • Each proxy only gets the branches it can reach. A type with one boolean property does not need the same routing code as a background with gradients, patterns and nested templatable color state.

Here is a simplified version of the generated background-fill code. The real generator includes additional overloads and guards, but the routing shown here is representative:

// Auto-generated shape, shortened and simplified for readability.

public sealed partial class TemplateBackgroundFillValue
{
    // Preview properties are decorated with [MemoryPackIgnore][JsonIgnore].
    internal TemplateProperty_Templatable<TemplateColor>? PreviewColor { get; set; }
    internal TemplateProperty_Templatable<TemplateColor> DisplayColor => PreviewColor ?? Color;

    public bool AnyPreview =>
        Gradient.AnyPreview ||
        PreviewColor != null ||
        Pattern.AnyPreview;
}

Most of this visible work does not require the block component to rerender. Its styles live in a separate stylesheet component, which watches the block's normal, preview and variable-context versions. A preview changes that small component, and the browser applies the resulting CSS to the existing DOM. The CSS producer handles the full path from those preview values to declarations targeted at the existing block markup.

Drag and resize have a slightly different shape. The TypeScript runtime handles the live movement close to the browser. It writes temporary visual state while the pointer is moving, then sends a small structured commit event back into C#. C# takes that final frame and applies the real tracked updates inside a transaction.

The editor can feel immediate without making the history stack useless. Generic input components also stay small: they send an address and a value, and generated C# handles the routing.

Conclusion

An edit starts in a fairly loose place: a generic property input, a collection editor or a browser interaction. The compact address lets that code name a destination without knowing the concrete type behind it. Once the call reaches generated C#, typed methods take over again. A transaction then keeps every property changed by the same gesture together.

There is more machinery here than taking a snapshot of a block. In return, validation knows exactly what changed, undo groups changes around the action the user took, and downstream systems can respond without diffing the model afterward. The serializable template model stays blissfully unaware that a color was previewed 40 times before one value was committed.

From the user's perspective, a resize is one resize, undo reverses it once, and a color picker responds as quickly as they can drag it. That is what people expect from an editor, even if making it happen took a surprising amount of code.

Template Editor Engineering Series

Post Description
Building a Visual Template Editor in Blazor WASM A high-level tour of the decisions, architecture, and shared systems behind the editor.
Building Consistent Blazor UX with Source Generators Turning typed C# models into consistent property controls, previews, and rendered blocks with source generators.
Undo, Redo, and Live Preview - No Diffs Needed Compact property addresses, generated tracking facades, transactions, undo/redo, and preview state.
Why Our Blazor App Still Uses TypeScript A practical split between Blazor-owned application state and TypeScript-owned browser behavior.
Beyond Text Replacement: Building Typed Template Variables Keeping Handlebars for content while extending variables to colors, sizes, images, and other typed properties.
Building a System to Generate Complex CSS Generating deterministic CSS for nested blocks, panels, previews, and both browser and server renderers.
Simplifying Complexity: MemoryPack to the Rescue MemoryPack storage, read-time schema repair, and typed MagicOnion communication across browser and server.
Productionizing Our Blazor WebAssembly Editor Runtime performance, trimming, loading experience, and browser-backed testing for a production Blazor WASM editor.

Get new posts in your inbox

Occasional HTML/CSS to Image updates and posts. No marketing sequences.

Unsubscribe whenever you like.

Have a question?

We'd love to hear about what you're building.

support@htmlcsstoimage.com
Get Started

You'll be up and running in 5 minutes.

Grab an API Key