Skip to content

2.0.0

Choose a tag to compare

@unadlib unadlib released this 18 Jul 11:44
· 90 commits to main since this release

Travels 2.0.0

Travels 2.0 is a hardening and performance release built around one idea: the same patch pipeline that powers undo/redo should be trustworthy enough to persist, replay, and observe — atomically and at scale. This major locks the state contract down to durable JSON, makes observer publication transactional, adds an opt-in semantic validator for untrusted snapshots, and removes the largest hot-path costs measured in 1.x.

Highlights

  • One TravelsEvent object for subscribers and devtools — extensible, frozen, lazily materialized, and carrying event-local patch deltas instead of full-history clones.
  • Semantic persistence validation (validation: 'semantic') that replays and reverses every persisted entry before trusting a snapshot.
  • Transactions are now O(1) — rollback is journaled instead of deep-snapshotting state and history.
  • development-condition bundles — bundler dev builds finally get the full compatibility diagnostics; production bundles stay diagnostic-free.
  • JSON-only state contract — Map/Set support is removed, so what runs is exactly what persists.

💥 Breaking Changes

1. Subscribers and devtools receive a single TravelsEvent

Positional listener arguments are gone. Both observer channels now share one frozen event envelope:

// Before (1.x)
travels.subscribe((state, patches, position) => {
  render(state, position);
});

// After (2.0)
travels.subscribe(({ type, state, patches, position, historyLength, metadata }) => {
  render(state, position);
});
  • event.patches is the event-local forward/inverse delta that transforms the previously published state — not a clone of the whole retained history. Root transactions compose their internal steps into one delta; archive() and rebase() publish empty deltas. Call getPatches() when you need a full-history snapshot.
  • event.historyLength reports the number of retained entries.
  • The delta is materialized lazily: listeners that never read event.patches never pay for cloning.
  • TravelsDevtoolsEvent remains as a deprecated alias of TravelsEvent.
  • In development, subscribing a legacy positional callback (declared arity > 1) logs a one-time migration warning, because such listeners would otherwise fail silently inside observer isolation.

2. Map and Set are no longer supported state

Both runtime modes fail fast when a Map or Set appears in initial state or in a produced patch payload — including collections created in another JavaScript realm — and restored snapshots containing them fail structural validation. Normalize collections to plain objects or dense arrays; see the migration guide. Do not replay pre-2.0 patch history recorded from Map/Set mutations — rebase on a normalized current state instead.

3. Durable patch-path contract for restored histories

Restored patch paths must be JSON Pointer strings or plain dense arrays of strings/non-negative integers. Runtime-only collection locators are no longer accepted.

4. validateTravelPatches replaces getTravelPatchesValidationError

The new API validates and returns canonicalized patch groups in one step ({ error } | { error: null, patches }). The allowNonJsonPathSegments escape hatch is removed together with the durable path contract.

5. Persistence callbacks must be synchronous

migrate and function fallback results that are Promise-like are rejected with MIGRATION_FAILED / FALLBACK_FAILED (and consumed without unhandled rejections). TravelsMigration is typed accordingly.

6. Peer dependency: mutative@^1.3.0

History composition, semantic replay, and journal rollback are validated against the ^1.3.0 line; the previous >=1.0.0 range is no longer claimed.

✨ New

  • Travels.deserialize(input, { validation: 'semantic', replayOptions }) — replays every entry forward and inverse on an isolated graph and verifies round trips before accepting a snapshot. Failures throw TravelsPersistenceError with code: 'INVALID_HISTORY' plus the failing entryIndex and direction. Structural validation remains the fast synchronous default; semantic mode is recommended once per trust boundary (untrusted storage, cross-tab, sync payloads). Validation proves replay consistency, not provenance — pair it with a checksum or signature when origin matters.
  • onObserverError — rejected or throwing listeners, devtools hooks, and lifecycle callbacks are isolated and reported here instead of surfacing as unhandled rejections or breaking committed transitions.
  • development export condition — Vite dev server and webpack mode: 'development' load dist/index.dev.* bundles with the full state-compatibility scanner (state, patch payloads, metadata, touched-path incremental scans). Default, require, and UMD entries are production-only and diagnostic-free.
  • ARRAY_SHAPE / OBJECT_SHAPE diagnostics — development scans flag sparse arrays, custom prototypes, accessors, and non-durable property shapes before JSON persistence silently changes them, without ever invoking accessors.

⚡ Performance

Measured on the repository benchmarks and microbenchmarks (Node 24; multipliers vs 1.4.0):

  • Transactions: rollback is journaled, so transaction() no longer deep-clones state and retained history — an empty transaction dropped from ~84µs to ~0.6µs at the default maxHistory, and cost is now independent of history length (~1,900× at 500 entries).
  • Observer events: listeners that read patches paid O(retained history) per event in 1.x (~240µs at 100 entries); event-local deltas make it O(change) (~7µs), and non-reading listeners pay nearly nothing.
  • setState hot path: primitive-only patch streams skip collection scanning entirely and validated payload roots are cached — small-update overhead vs a raw mutative.create fell from ~2.2× to ~1.3×, and large fresh-payload commits run ~2× faster than 1.4.0.
  • Semantic validation: ordinary object graphs are isolated once and reused across entries and directions — ~5× faster on the reference benchmark (structural validation stays ~50× faster still; it remains the default).
  • Development mode: compatibility scans now walk only the state paths touched by each commit (immutable mode), making dev-mode small updates ~38× faster on large states; mutable mode intentionally keeps full scans.
  • Superseded root-replacement operations are discarded during composition, branch snapshots are skipped when no onBranchDiscard hook is configured, and pending manual entries are composed without re-diffing the full state.

🛡️ Correctness & Robustness

  • Atomic observer publication: listeners, devtools, and branch-discard hooks run only after a transition commits; failed transactions publish nothing and roll back silently.
  • Transaction error discipline: nested onError reports are deferred until the root transaction settles, and a failure bubbling through nested scopes is reported once.
  • onBranchDiscard timeline alignment: root transactions report only entries visible before they began, reconciled by entry identity across reset, rebase, nested rollback, and provisional branches.
  • Semantic replay soundness: key-order-neutral round trips for plain objects (JSON Patch re-appends re-added keys), array length and hole-topology comparison on detached graphs, conservative rejection of unverifiable shapes (custom prototypes, subclasses, descriptor or extensibility drift, non-zero RegExp.lastIndex), metadata included in isolation, and linear key comparison to keep wide snapshots safe from quadratic validation.
  • Input hardening: snapshot and patch-operation fields are captured once from own data properties, so accessors cannot bypass validation or execute during cloning; sparse, extended, or custom-prototype history arrays are rejected; auto-freezing is deferred until Map/Set validation succeeds so rejected updates leave caller-owned values untouched.
  • Mutable-mode rollback scope is documented: the journal reverses changes made through Travels APIs; direct writes to the live object during a transaction survive rollback by design.

📚 Documentation

  • Plain dense arrays defined as the durable persistence contract.
  • The provenance boundary: replay validation detects malformed histories, not forged-but-consistent ones — external anchors (checksum, signature, revision) close that gap.
  • Mutable transaction rollback scope, Map/Set migration, positional-listener migration, and persistence migration guides.

Upgrading

  1. Follow the migration guide — positional subscribe callbacks and Map/Set state are the two changes most codebases will notice.
  2. use-travel and zustand-travel currently pin travels@^1.3.1 and will not resolve 2.0 until their companion majors land — keep using them with Travels 1.4 until then.
  3. Snapshots produced by 1.x serialize() restore unchanged under structural validation as long as they respect the durable contract; adopt validation: 'semantic' at trust boundaries when you're ready.

Full Changelog: v1.4.0...v2.0.0