# How Supersplat Edit History and Undo/Redo Functionality Works

> Learn how Supersplat's Edit History and Undo/Redo works. Discover its async-aware manager that serializes mutations via Promises to prevent race conditions.

- Repository: [PlayCanvas/supersplat](https://github.com/playcanvas/supersplat)
- Tags: internals
- Published: 2026-05-10

---

**Supersplat implements a linear undo/redo system using an async-aware EditHistory manager that serializes mutations through a Promise chain to prevent race conditions during GPU operations.**

The `playcanvas/supersplat` repository provides a specialized editor for 3D Gaussian splats. Its **edit history** system is designed to handle asynchronous GPU read-backs safely while maintaining a clean undo stack. The architecture separates individual operations from the history manager itself, communicating through a global event bus.

## Edit Operations (EditOp)

All reversible changes in Supersplat implement the `EditOp` interface defined in [`src/edit-ops.ts`](https://github.com/playcanvas/supersplat/blob/main/src/edit-ops.ts). This contract ensures every operation knows how to apply and reverse itself.

```typescript
interface EditOp {
    name: string;
    do(): void | Promise<void>;
    undo(): void | Promise<void>;
    destroy?(): void;
}

```

Concrete implementations in the codebase include:

- **StateOp** – Toggles selection, visibility, or deletion states in a splat's `state` buffer.
- **EntityTransformOp** – Modifies a single splat's transform matrix.
- **SplatsTransformOp** – Applies transforms to multiple selected splats simultaneously.
- **AddSplatOp** and **SplatRenameOp** – Handle scene composition changes.

All operations are **async-capable**. When an operation returns a `Promise` (for example, waiting for GPU memory read-back), the history system automatically awaits completion before proceeding to the next mutation.

## EditHistory Manager and Cursor System

The central coordinator lives in [`src/edit-history.ts`](https://github.com/playcanvas/supersplat/blob/main/src/edit-history.ts). It maintains a linear stack with a cursor-based navigation system rather than branching history trees.

### Core Data Structure

The `EditHistory` class tracks:

- `history: EditOp[]` – The complete array of operations performed.
- `cursor: number` – Points to the next operation to redo; all items before the cursor represent applied state.
- `chain: Promise<void>` – A serialization queue that forces sequential execution.

### State Transitions

When you trigger an action, the manager updates the cursor position and applies the relevant operation:

- **`add(editOp)`** – Clears any redo-future (operations after the cursor), pushes the new operation to the stack, and executes it.
- **`undo()`** – Moves the cursor backward and calls `undo()` on the operation at the new cursor position.
- **`redo()`** – Executes `do()` on the operation at the current cursor, then advances the cursor forward.

After every undo or redo, the system fires an `edit.apply` event so UI components can refresh their state.

### Splat Deletion Handling

The `removeForSplat(splat)` method walks the history stack and removes any operations referencing a deleted splat. This prevents dangling references when a user permanently removes a splat from the scene using `clear()` or explicit deletion.

## Multi-Operation Support

Complex edits that involve multiple discrete changes (such as moving a selection and updating a palette) use the `MultiOp` class. This aggregator implements the same `EditOp` interface but loops over an array of sub-operations.

```typescript
import { SplatsTransformOp, SplatRenameOp, MultiOp } from './edit-ops';
import { events } from './events';

const moveOp = new SplatsTransformOp({ splat, transform: myMatrix, paletteMap });
const renameOp = new SplatRenameOp(splat, 'NewName');

const compound = new MultiOp([moveOp, renameOp]);
events.fire('edit.add', compound);

```

Because `MultiOp` awaits each inner operation sequentially, the entire group is treated as a single atomic entry in the undo stack. Pressing **Ctrl+Z** once reverses both the transform and the rename simultaneously.

## Event Bus Integration

The history system decouples UI widgets from the underlying manager through the global `Events` bus defined in [`src/events.ts`](https://github.com/playcanvas/supersplat/blob/main/src/events.ts). During construction, `EditHistory` subscribes to three primary events:

```typescript
events.on('edit.undo', () => this.undo());
events.on('edit.redo', () => this.redo());
events.on('edit.add', (editOp, suppressOp = false) => this.add(editOp, suppressOp));

```

UI components never call history methods directly. Instead, they fire events:

```typescript
// Trigger an undo
events.fire('edit.undo');

// Add a selection toggle
import { SelectInvertOp } from './edit-ops';
const toggleOp = new SelectInvertOp(splat);
events.fire('edit.add', toggleOp);

```

This pattern allows keyboard shortcuts, toolbar buttons, and context menus to interact with the history stack without importing the history module directly.

## Race Condition Safety via Promise Chains

The `EditHistory` class prevents concurrent GPU operations through a **promise chain** pattern (see comments at lines 20-23 in [`src/edit-history.ts`](https://github.com/playcanvas/supersplat/blob/main/src/edit-history.ts)). Every mutating method (`add`, `undo`, `redo`, `clear`, `removeForSplat`) wraps its work in a `queue` function:

```typescript
queue(fn) {
    const next = this.chain.then(fn);
    this.chain = next.catch(err => console.error('EditHistory queued operation failed', err));
    return next;
}

```

Because each operation appends to `this.chain`, **no two edits run concurrently**. This eliminates buffer corruption that could occur if a user rapidly pressed **Ctrl+Z** while a GPU read-back was still pending. The system guarantees that undo/redo actions always operate on known-good state.

## Summary

- **Edit operations** implement a standard interface with `do()` and `undo()` methods, supporting both sync and async execution.
- **EditHistory** maintains a linear stack with a cursor index, discarding redo history whenever a new operation is added.
- **MultiOp** groups multiple edits into atomic units that occupy a single slot in the history.
- **Event bus decoupling** allows UI components to trigger history actions without direct dependencies.
- **Promise chain serialization** ensures GPU-intensive operations complete before the next mutation begins, preventing race conditions.

## Frequently Asked Questions

### How does Supersplat prevent race conditions during undo/redo?

Supersplat uses a promise chain (`this.chain`) in [`src/edit-history.ts`](https://github.com/playcanvas/supersplat/blob/main/src/edit-history.ts) to serialize all mutations. Every call to `add()`, `undo()`, or `redo()` is wrapped in a `queue()` method that appends the operation to the chain. This ensures that rapid user inputs cannot interleave with pending GPU read-backs, which prevents buffer corruption as noted in the source code comments around lines 20-23.

### Can edit operations be asynchronous in Supersplat?

Yes. The `EditOp` interface explicitly allows `do()` and `undo()` to return `void | Promise<void>`. The `EditHistory` manager awaits these promises before advancing the cursor or processing the next queued action. This design supports operations that require GPU synchronization or other async resources.

### What happens to the history when a splat is deleted?

When a splat is removed from the scene, the `removeForSplat(splat)` method traverses the `history` array and filters out any operations referencing that specific splat. This prevents the undo system from attempting to reverse operations on objects that no longer exist, maintaining referential integrity.

### How are multiple operations grouped into a single undo step?

Supersplat provides the `MultiOp` class in [`src/edit-ops.ts`](https://github.com/playcanvas/supersplat/blob/main/src/edit-ops.ts). It accepts an array of `EditOp` instances and implements the same interface, sequentially executing `do()` or `undo()` on its children. Because `MultiOp` itself is a single `EditOp`, it occupies only one position in the history stack, allowing users to undo complex compound actions with a single keyboard shortcut.