Repository navigation
3.0.0
Travels 3 makes its public capabilities match the ownership model already used internally: state-owning timelines, externally owned journals, shared read views, and persistence restoration now have distinct, explicit contracts. See the 2.x to 3.0 migration guide.
Breaking Changes
- Reject
enableAutoFreeze: truetogether withmutable: truewithINVALID_OPTION, and report the pair at compile time whenenableAutoFreezeis the literaltrue. Mutable mode updates the live state in place, and freezing each produced state also froze the parts it shared with the live state, so the next update that wrote to them threw aTypeError. - Require
mutative@^2.0.0as the peer dependency. Mutative 1.x is no longer supported: its patches could not undo some recorded changes, such as an array shortened and lengthened again in one update or an element changed between0and-0. Mutative 2 builds target ES2018 and select their development or production build fromprocess.env.NODE_ENV; see the migration guide. - Retained patches in immutable mode now share objects with the states they produced, because Mutative 2 no longer deep-copies patch values. Mutating a state outside Travels operations, which was already unsupported, now also rewrites the history that redo replays. Mutable mode still stores detached patch values.
- Replace the public
Travelsconstructor with thecreateTravels()andcreatePatchableTravels()factories. TheTravelsvalue is now a frozen persistence namespace containingTravels.deserialize(), soinstanceof Travelsthrows; use the newisTravels()guard. The internal constructor reachable throughinstance.constructorrejects direct construction withINVALID_OPERATIONinstead of skipping factory validation. - Publish
Travelsas an explicit state-owning capability interface and make itsmutablemode flag read-only. Mutable behavior is fixed when the factory creates the underlying state driver and cannot be changed afterwards. - Remove
controlledApplyfromTravelsOptions. Create externally owned timelines withcreateTravelJournal(initialState, { apply })instead. - Separate state and journal capabilities at runtime as well as in TypeScript. State-owning timelines no longer expose
recordPatches(), while aTravelJournalis an explicit facade that omitssetState(), transactions, tracking,replaceStateWithoutHistory(), andgetControls(). Journal navigation andreset()are delegated through the external owner;rebase()remains journal-local, and manual journals exposearchive()andcanArchive(). - Expose
archive()andcanArchive()only on timelines created withautoArchive: false. Automatic timelines omit both capabilities at runtime and in TypeScript instead of retaining a warning-onlyarchive()method. - Align journal generic parameters with the state-owning interface as
<S, F, A, P>. v2 code that explicitly supplied patch options as the third argument must insert the archive mode before them; inferred call sites are unchanged. - Drop the unused auto-freeze parameter from
TravelsControls,RebasableTravelsControls,ManualTravelsControls, andRebasableManualTravelsControls, which are now<S, P>, and remove the unusedValueandInitialValuehelper types. - Make
TravelsControlsa read-only capability object and change both sharedgetHistory()surfaces to returnreadonly ReadonlyState<S>[]. Controls, journal facades, and shared history array containers are frozen in every build. UsegetHistorySnapshot()for detached mutableS[]values, independently of the auto-freeze option. - Return
controls.patchesas a cached, frozenReadonlyTravelPatchesview that is rebuilt once per history change. 2.x deep-cloned the whole retained history on every property read, which cost about 0.9 ms per read at 1000 entries and produced a new reference each time. UsegetPatches()for a detached copy. - Expose observer state as a borrowed
ReadonlyState<S>view and deltas asReadonlyTravelPatches. The shared patch snapshot, its group arrays, operations, and array paths are frozen when lazily materialized. Top-level metadata is lazily cloned and frozen; nested patch and metadata values remain shallow, event-local views shared by its observers. - Remove the deprecated
TravelsDevtoolsEventalias and theLEGACY_SUBSCRIBERarity warning. Subscribers and devtools useTravelsEventexclusively. - Remove the split
TravelsHistorytype plushistory,initialPatches,initialPosition, andstrictInitialPatchesfromTravelsOptions. Restore one completeTravelsSerializedHistorythroughTravels.deserialize()andrestoreTravels(); malformed history now throws instead of silently starting from an empty timeline. - Archive pending manual work as part of the navigation that implies it.
back(),forward(), andgo()with pending changes now publish a singlegoevent and advance the revision once in both immutable and mutable mode, where 2.x publishedarchivefollowed bygo. The pending entry is committed only after the navigation succeeds. - Remove the
deserializeTravelsHistoryexport. It duplicatedTravels.deserialize(), which remains the single public decoder. - Require Node.js 22 or newer. CI and release verification cover Node.js 22 and 24.
Added
- Add
ReadonlyTravelPatchandReadonlyTravelPatchesfor observer-facing patch views. - Add the
restoreTravels(snapshot, { mutable: true, target })option for restoring persisted history into an existing observable store object, which replaces the 2.xnew Travels(store, { mutable: true, history })pattern. - Add
isTravels()to identify state-owning timelines withoutinstanceof. - Add
getRevision(), an O(1) monotonic subscription token that advances once for each committed observable timeline event, including history-only changes. - Add
assertTravelHistoryEntryRecordable()so external owners can verify that a patch pair and its metadata can be safely detached before publishing the corresponding state. - Complete the controlled-journal lifecycle with owner-delegated
reset()and manual archive mode while preserving the frozen, ownership-safe facade. - Add package-level type and runtime smoke checks for the factory-only API, capability separation, read-only shared views, and unified restore path.
Changed
- Type the
devtoolshook event with the timeline state.TravelsOptionsgains an optional fourthSparameter (defaultany), which every factory fills in, sodevtools(event)seesTravelsEvent<S>instead ofTravelsEvent<any>. Hooks that read fields the state does not have, or that annotate their event with a different state type, now fail to compile. - Rename the internal restored reset state to a baseline model, keeping transaction rollback,
reset(), andrebase()aligned with atomic snapshot restoration. - Validate complete restore snapshots structurally even when a JavaScript caller bypasses TypeScript, and reject the removed
historyoption at runtime. Persistence from storage should still go throughTravels.deserialize(..., { validation: 'semantic' })when its provenance is not trusted. - Report an unknown
Travels.deserialize()validationmode asTravelsTypeErrorwith codeINVALID_OPTIONinstead of a plainTypeErrorwithout a code. - Report retained patches that cannot be applied, from navigation and
getHistory(), asTravelsErrorwith the new codeHISTORY_REPLAY_FAILEDand the Mutative error as itscause, instead of that error alone, which production builds of Mutative 2 reduce to an error code. A mutable timeline keeps the in-place writes made before the failing operation, so validate untrusted snapshots semantically before restoring them. - Reject defined non-boolean
autoArchiveandmutablevalues at runtime instead of selecting capability modes through JavaScript truthiness; omitted values retain their defaults. - Reject a defined
onError,onBranchDiscard,onObserverError,onWarning, ordevtoolsoption, and asubscribe()listener, that is not a function withINVALID_OPTION. Travels accepted them and called them later inside the observer isolation, which swallowed the resultingTypeError, soonWarning: {}silently dropped every warning. - Update persistence examples, browser fixtures, API documentation, and the release workflows for the 3.0 contracts.
- Strip the implementation class and internal module graph from published declarations and prune unreachable declaration artifacts.
- Grow the production bundle from 48.4 KiB raw / 13.4 KiB gzip in 2.2.0 to 57.1 KiB raw / 15.5 KiB gzip (ESM). Option removals are outweighed by the ownership guards, atomic restore factories, restore targets, construction boundary, and cached control views. The size budgets were raised to match and sit just above the 3.0.0 artifacts.
Fixed
- Keep an own
__proto__key, such as oneJSON.parseproduced, when Travels copies state, patch values, or metadata thatstructuredClone()rejects, for example because they hold a function. The fallback copy assigned each key, which replaced the copy's prototype instead, soreset(),serialize(), and mutable undo and redo lost the key and exposed the properties of the object it held. - Treat a root transaction whose
setState()updates cancel out, such as setting a field and then setting it back, as the no-op a single update that changes nothing already was. It recorded an entry that undid nothing, advanced the revision, published atransactionevent, discarded the redo branch, and in manual mode archived earlier pending changes; it now leaves the timeline as it found it.archive()likewise drops pending changes that cancel out instead of archiving them. Travels compares values the way Mutative does, so a value replaced by an equal copy still counts as a change. - Return
getHistorySnapshot()copies of history that holds values JSON would drop or change but that copy faithfully, such asundefined,NaN,-0,bigint, aDate, a null-prototype object, or a shared or circular reference. The method enforced the whole durable JSON contract, so an optional field leftundefinedthrewPERSISTENCE_INCOMPATIBLEalthoughserialize()accepted the same history. It still refuses functions, accessors,Map,Set, class instances, and other runtime objects, and is about twice as fast because it copies in the same pass that checks. - Reduce the state paths a commit wrote in linear time before the development-only compatibility warnings inspect them. Each written path was compared with every kept one, so one
shift()on an array of 40,000 items took 8.8 s in a development build and 80,000 items took 48 s; they now take 0.2 s and 0.5 s. Production builds do not run these checks. - Compose a history entry with more operations than a function call accepts as arguments, about 125,000 in V8, such as one
shift()on an array of 130,000 items. The operations were spread intopush(), which threw aRangeError, soback()failed, a manual timeline'sgetPatches(),serialize(), andarchive()threw, andtransaction()threw after applying the change, leaving the position and history out of step. - Replace a root that has an own
__proto__key, such as oneJSON.parseproduced, when a mutable timeline applies a value update or resets and when a journal resets. Overwriting such a root key by key assigned__proto__on a Mutative draft, which threw an untyped Mutative error.restoreTravels()rejects such a root astargetwithINVALID_OPTION, because a target must be overwritten in place. - Restore the whole timeline when a failed transaction cannot undo every in-place change of a mutable state. The rollback stopped at the first change it could not undo, leaving fields such as the cursor unrestored, and its error replaced the transaction's. It now undoes the remaining changes and throws
TRANSACTION_FAILEDwith the original error as itscause. - Treat an update that passes or returns the current state object as a no-op, as returning the draft already is. It recorded an empty root replacement and published an event, and mutable timelines also warned about the replacement.
- Set an
undefinedroot throughsetState(undefined)andreplaceStateWithoutHistory(undefined). The value was taken for a recipe that made no change, sosetState()ignored it andreplaceStateWithoutHistory()cleared the history but kept the previous state. - Reject a
setState()change under a__proto__key, or inside a value stored at aconstructorkey, withUNSUPPORTED_STATEbefore it applies. Mutative will not replay such patches, so immutable timelines recorded an entry that could not be undone or restored, and mutable timelines threw an untyped Mutative error. - Pass a draft-free root returned by a mutable timeline's function updater to Mutative through
rawReturn(), as immutable timelines already do. Mutative searched it for drafts and printed a development warning thatonWarningcould not receive. - Reject Map and Set patch values in
validateTravelHistoryEntry()again, asvalidateTravelPatches()does. 2.2.0 left that check torecordPatches(), so the public validator accepted entries thatrecordPatches()then rejected;recordPatches()still walks each value only once. - Detach the reset patches of a mutable timeline whose root kind no longer matches its baseline. That reset replaces the root, and its event patches held the new live root itself, so the next in-place update also rewrote the patches that observers read later or that a transaction event composed.
- Keep retained history out of a mutable timeline's live state. Navigation across a root replacement replays immutably, and Mutative uses the replacement's value as the next state, so the next in-place update also rewrote the history entry holding that value and later navigation returned the edited data.
- Reset a controlled journal whose current state and baseline are both plain objects, or both arrays, with patches that overwrite the root's entries instead of replacing the root. Owners that apply transitions in place, such as with Mutative's
apply(state, patches, { mutable: true }), can now reset: Mutative 1 skipped the root replacement and left the owner's state unchanged while the journal moved to its baseline, and Mutative 2 throws for it. - Report an object reached through two paths without a cycle as
SHARED_REFERENCEinstead ofCIRCULAR_REFERENCE. Durable-state checks such ascreatePatchableTravels()still reject it, because JSON duplicates shared references, but the diagnostic no longer claims a cycle. AddSHARED_REFERENCEtoStateCompatibilityIssueCode. - Stop recloning every retained metadata entry each time a commit trims history at
maxHistory. Commits at capacity were O(maxHistory) structured clones: 500 labelled commits took 331 ms atmaxHistory: 1000and 3.5 s at 10000, and now take 4 ms and 31 ms. - Detach
restoreTravels()state from the snapshot. Mutable timelines restored from a reusedfallbacksnapshot previously wrote their edits into it, so the next restore started from another document's contents. - Reject journal mutations re-entered from the external owner's
applycallback, and stage implicit manual archives until delegated navigation succeeds so cursor, pending work, revision, and notifications remain atomic. - Normalize root JSON Pointer paths to the canonical empty string when string paths are configured, both for newly produced and restored patch history.
Full Changelog: v2.2.0...v3.0.0