travels

repository·main·Indexed 21 days ago

https://github.com/mutativejs/travels

A fast, framework-agnostic undo/redo core library powered by Mutative JSON Patch. Optimized for large application states and long histories, travels uses JSON Patches to minimize memory usage and facilitate persistence. It supports both immutable and mutable modes and provides integrations for React, Zustand, Vue, Pinia, and MobX.

Tokens
29.2K
Snippets
73
Records
107
Agent score
69%

What's inside travels

  1. Explore Travels integration examples

    main

    The travels repository provides several integration examples demonstrating how to use Travels with different state management libraries and complex application workflows. These examples move beyond simple counters to show product-shaped state management.

    Framework Integrations

    • React: Integration using useSyncExternalStore for efficient subscriptions.
    • Zustand: Using Zustand as the UI store while leveraging Travels as the underlying history engine.
    • Vue: Implementation via a Vue composable wrapper.
    • Pinia: Setting up a Pinia store using Travels' mutable mode.
    • MobX: Managing MobX observable state using mutable mode.

    Advanced Workflows

    • Form Builders: Using archive mode to manage multi-step form builder edits manually.
    • Canvas/Editors: Implementing pointer-move batching for high-frequency editor workflows.

    Persistence Patterns

    • Versioned Persistence: Using localStorage with versioning.
    • Custom Adapters: Patterns for localStorage, IndexedDB, and compression adapters.
    • Local-first: Combining IndexedDB with BroadcastChannel for local-first persistence strategies.
  2. Select an integrity mechanism based on your trust model

    main

    Choose how to handle data integrity based on your application's requirements:

    ScenarioHost-application responsibility
    Ephemeral UI undo/redoTravels replay validation and a known-safe fallback are normally sufficient.
    Important offline draftsChecksum the exact encoded bytes, store a monotonic revision, and retain at least one previously verified generation.
    Multi-device synchronizationBind document and tenant/user identity to a server-controlled revision, and resolve stale or conflicting snapshots before deserialization.
    Tamper-resistant storageVerify a server-held HMAC, digital signature, or authenticated-encryption tag.
    Audit-grade historyKeep a server-authoritative append-only event log and a trusted signed chain head. Treat Travels snapshots as reconstructable client caches.
  3. How `maxHistory` affects the history window

    main

    The maxHistory option limits the number of patches (transitions) kept in memory. It does not limit the number of states directly.

    When the limit is reached:

    • The oldest patches are discarded.
    • The current position is capped at maxHistory.
    • You can still access maxHistory + 1 states (the window start plus the number of transitions).
    • reset() can always return to the true initial state, even if it was trimmed from the history window.

    Example Behavior (maxHistory: 3): If you perform 5 increments, the library keeps the last 3 transitions. You can go back to the state at position 0 (the window start), but you cannot go back to the original state using back()—you must use reset() for that.

    const travels = createTravels({ count: 0 }, { maxHistory: 3 });
    
    // After 5 increments:
    // Position is capped at 3. 
    // History contains patches for transitions: 2->3, 3->4, 4->5.
    // You can go back to position 0 (count: 2).
    // To get back to count: 0, you must call travels.reset().
  4. When to use Travels vs Snapshot-based systems

    main

    Travels is a patch-based undo/redo system. Choose your strategy based on your application's needs:

    ScenarioRecommended Approach
    Small state, short history, local-onlySnapshot stack (e.g., Redux-undo, Zundo)
    Large state, small updates, long history, persistenceTravels
    Collaborative editing / Conflict mergingCRDT/OT system (Travels does not solve conflicts)

    Travels Strengths:

    • Memory-efficient history (stores only differences).
    • Persistence-friendly (small patches are easy to serialize/store).
    • Fast immutable updates via Mutative.

    Travels Trade-offs:

    • Higher latency for tiny states due to patch generation overhead.
    • Large 'replace-everything' updates may approach the cost of snapshots.
  5. Understand benchmark metrics

    main

    When interpreting benchmark results, consider the following:

    • Retained heap: Measures the heap delta after forcing Garbage Collection (GC). This is a noisy metric; focus on large differences rather than small rankings.
    • Update and navigation latency: Update time covers the loop recording history entries. Undo and redo cover the calls to navigate history. Lower is better.
    • Serialized history size: Compares the durable representation size. Patch histories typically have a smaller serialized size for small updates compared to snapshot histories, which repeat the reachable state for each entry.
  6. Core Concepts of Travels

    main

    Understanding these terms is essential for working with the library:

    • State: Your application data (e.g., { count: 0 }).
    • Draft: A temporary mutable copy of your state used within setState. You modify the draft directly, and Travels automatically converts these mutations into immutable updates.
    • Patches: JSON Patch operations (RFC 6902) that represent the differences between states. Travels stores these instead of full state snapshots to save memory.
    • Position: Your current location in the history timeline. Position 0 is the initial state. Moving back() decreases position; forward() increases it.
    • Archive: The process of saving the current state to history. By default, every setState call archives automatically, but this can be controlled manually.
  7. State Requirements and Compatibility

    main

    Travels requires JSON-compatible state for persistence and patch generation.

    Supported Types:

    • Plain objects
    • Dense arrays
    • Strings
    • Finite numbers (except -0)
    • Booleans
    • null

    Unsupported Types:

    • Map and Set (updates and construction will be rejected)
    • bigint, NaN, Infinity, and -0 (require normalization before persistence)
    • Date, class instances, null-prototype objects, DOM nodes, refs, and functions.

    Note on Collections: Always normalize Map and Set into plain objects or dense arrays before passing them to Travels.

  8. How Travels persistence works

    main

    Travels is storage-agnostic. To persist history, you must store the versioned snapshot returned by travels.serialize(). To restore, use Travels.deserialize(...) to validate the data, then pass the resulting history into createTravels(...).

    Data Constraints for Durable Persistence: To ensure compatibility with JSON and JSON Patch, your state and retained patch values must use:

    • Plain objects and dense arrays.
    • Strings, finite numbers (excluding -0, NaN, or Infinity), booleans, and null.
    • JSON Pointer strings or dense arrays of strings/finite non-negative integers for paths.

    Unsupported Types:

    • Map and Set are rejected as state or patch payloads.
    • bigint must be encoded.
    • Array holes and custom prototypes are not preserved; fill holes with null and use plain objects/arrays.
    // To persist:
    const snapshot = travels.serialize();
    // Store 'snapshot' in your database...
    
    // To restore:
    const history = Travels.deserialize<DocumentState>(rawSnapshot, {
      validation: 'semantic',
      fallback: createEmptySnapshot,
      onError(error) {
        if (error instanceof TravelsPersistenceError) {
          console.warn('Ignoring invalid persisted history:', error.code);
        }
      },
    });
    const travels = createTravels(history.state, { history });
  9. Control history recording with Archive Modes

    main

    Travels offers two modes to control when state changes are committed to the undo/redo history.

    Auto Archive Mode (autoArchive: true, default)

    Every call to setState is automatically recorded as a new, separate history entry. This is the simplest mode for standard applications.

    Manual Archive Mode (autoArchive: false)

    Changes made via setState are treated as temporary and are not added to the history until you explicitly call archive(). This allows you to batch multiple mutations into a single undo/redo step.

    ModeBehaviorUse Case
    Auto ArchivesetState = 1 undo stepSimple, direct state updates
    Manual Archivearchive() = 1 undo stepBatching multiple changes into one step

    Examples

    Batching multiple changes (Manual Mode):

    const travels = createTravels({ count: 0 }, { autoArchive: false });
    
    travels.setState({ count: 1 });
    travels.setState({ count: 2 });
    travels.setState({ count: 3 });
    
    // Commits all changes as a single entry
    travels.archive(); 
    
    // Undo goes back to 0, not 2 or 1
    travels.back(); 

    Explicit commit (Manual Mode):

    function handleSave() {
      travels.setState((draft) => {
        draft.count += 1;
      });
      travels.archive(); // Commit immediately
    }
    const travels = createTravels({ count: 0 }, { autoArchive: false });
    
    travels.setState({ count: 1 });
    travels.setState({ count: 2 });
    travels.setState({ count: 3 });
    
    travels.archive();
    
    travels.back();
  10. When to use Mutable Mode vs Immutable Mode

    main

    Use Mutable Mode when:

    • You are using observable state libraries (MobX, Vue/Pinia, custom proxies) where replacing the root object breaks reactivity.
    • You want to avoid the garbage-collection churn of creating new state copies.
    • You use autoArchive: false but need the live store reference to update immediately.

    Use Immutable Mode (Default) when:

    • You are using React/Redux style reducers or libraries like Zustand that rely on reference replacement.
    • You prefer structural sharing for diffing.
  11. Handle Travels events and compatibility

    main

    Travels emits events for successful operations. When consuming these events, ensure your event handler is resilient to future updates.

    Event Types:

    • recordPatches: Emitted after a successful external commit.
    • go: Emitted during navigation (including back() and forward()).

    Best Practice: TravelsEvent['type'] is an open string to allow for future minor-release event names. Do not use exhaustive type checking (like never in TypeScript) on the event type. Always include a default branch in your switch statements to handle unknown event types gracefully.

  12. Development diagnostics and compatibility scanning

    main

    In development mode, Travels automatically scans the initial state, changed state, patch operations, and history metadata for compatibility hazards (e.g., non-durable values).

    • Warnings: Travels identifies whether an incompatible path belongs to the state, a patch operation, or metadata. Warnings are repeated only once per diagnostic path.
    • serialize(): This method performs a diagnostic check against the current snapshot.
    • Production Behavior: The production build is diagnostic-free. To use the scanner, your bundler (Vite, webpack in development mode, etc.) must resolve the development export condition, which loads the dist/index.dev.* bundles.
    • Map/Set Invariant: Rejection of Map and Set is a production invariant. The warnOnUnsupportedState: false option only disables diagnostics for other compatibility hazards, not for Map or Set.