2.0.0
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
TravelsEventobject 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.patchesis 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()andrebase()publish empty deltas. CallgetPatches()when you need a full-history snapshot.event.historyLengthreports the number of retained entries.- The delta is materialized lazily: listeners that never read
event.patchesnever pay for cloning. TravelsDevtoolsEventremains as a deprecated alias ofTravelsEvent.- 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 throwTravelsPersistenceErrorwithcode: 'INVALID_HISTORY'plus the failingentryIndexanddirection. 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.developmentexport condition — Vite dev server and webpackmode: 'development'loaddist/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_SHAPEdiagnostics — 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 defaultmaxHistory, 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.createfell 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
onBranchDiscardhook 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
onErrorreports are deferred until the root transaction settles, and a failure bubbling through nested scopes is reported once. onBranchDiscardtimeline 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
- Follow the migration guide — positional subscribe callbacks and Map/Set state are the two changes most codebases will notice.
use-travelandzustand-travelcurrently pintravels@^1.3.1and will not resolve 2.0 until their companion majors land — keep using them with Travels 1.4 until then.- Snapshots produced by 1.x
serialize()restore unchanged under structural validation as long as they respect the durable contract; adoptvalidation: 'semantic'at trust boundaries when you're ready.
Full Changelog: v1.4.0...v2.0.0