Core Releases
Release notes sourced from the Core package changelog (Changesets).
Source: packages/core/CHANGELOG.md
Major Changes
Section titled “Major Changes”-
#152
c805d79- Release the final V1 Core API on a new, smaller shared runtime. This is a full replacement of the1.0.0-rc.2machine contract, not a compatible extension of it.Machine creation
Section titled “Machine creation”- Replace the all-purpose
createJourneyMachinefactory with explicitcreateLinearJourneyandcreateGraphJourneyfactories. Linear journeys use declared step order; graph journeys use declared event transitions. - Replace
createJourneyBuilderwithwithGraphTypes<Bag>(). Its singleBagtype namescontext,stepId,events, and optionalmeta,handlersandresults, instead of relying on positional generic parameters. Most definitions infer without it. - Context is no longer restricted to a JSON object at the type level. Runtime consumers are still responsible for serializability where persistence or DevTools transport requires it.
- Add an explicit
engines.node >=20.11.0package requirement.
Machine contract
Section titled “Machine contract”- Move commands into stable, purpose-specific namespaces:
machine.controls.{start,pause,resume,complete,terminate,restart},machine.navigate.{goToStepById,goToPreviousStep,goToNextStep,goToLastVisitedStep},machine.context.update,machine.async.clearError, andmachine.subscriptions.{subscribe,subscribeEvent}. - Lifecycle controls now return a boolean indicating whether the state change applied. Navigation
methods and graph
sendreturnPromise<NavigationResult>with explicit failure reasons instead of relying on thrown errors or implicit no-ops. - Rename lifecycle status
idledtoidle, add first-classpausedstate, make completion explicit, and store optional completion/termination payloads insnapshot.machine.outcome. - Allow
createLinearJourney<StepId, Context, TerminationPayloads>to enforce declared step ids, context, completion payloads, termination payloads, and the discriminated terminal outcome in snapshots without runtime type-carrier properties. - Default
autoStarttofalse.start()is accepted only fromidle;restart()is accepted only after completion or termination and restores the initial context and timeline. Termination wins over an in-flight transition. - Expose declared graph events and serializable outgoing transition descriptors in snapshots, including candidate priority, evaluated guard state, enabled state, and first-enabled selection.
- Remove the
requireExplicitCompletionandonLifecycleErroroptions. Completion is always explicit; work failures use navigation results and hook failures use typed error events. Subscriber failures are isolated from the machine and route through the optionalonListenerErrorcreation option (defaulting toconsole.error; a throwing reporter falls back to that default). - Make
dispose()irreversible but safe: listeners are dropped and subsequent machine operations become no-ops or rejected results instead of throwing a dedicated disposed error. - Replace broad, unfiltered event subscriptions and lifecycle-specific methods with typed
subscribeEvent(eventName, listener)alongside a plain per-commitsubscribe(listener).
Snapshots, navigation, and hooks
Section titled “Snapshots, navigation, and hooks”- Replace the old computed/meta getters with immutable snapshots discriminated by
type: "linear" | "graph". Shared snapshot state now includes lifecycle flags, context, transition state, browser-like history, outcome, plugin extensions, and current-step async state. - Linear snapshots expose declared order, position, first/last flags, and visit counts. Graph
snapshots expose enabled
availableEvents, enabledavailableSteps, and terminal-step state. - Use a browser-like timeline: moving backward or forward preserves existing entries, while a new navigation from the middle truncates the abandoned forward branch. Multi-step timeline jumps run leave/enter hooks once for the actual source and destination.
- Add transactional work to
goToNextStepandgoToPreviousStep. Its asynchronousrunmust succeed before movement; its synchronouscommitstages context updates that publish atomically with the destination. Failure keeps both source and context unchanged and returnsreason: error. - Make
onLeave, graphonTransition, andonEnterawaited post-commit effects. They run in that order, cannot roll navigation back, do not skip later effects after failure, and report failures through snapshot async state plus the typederrorsubscription event. - Give hook arguments the current snapshot, source, destination, causing graph event, immediate
updateContext, and FIFOraise. Raised graph events run only after the current transition settles and are capped by the exportedMAX_RAISED_EVENTSguard. Hook context updates remain immediate side effects after commit. - Keep linear
goToStepByIdas an ungated direct-jump escape hatch. Occasional exceptional jumps can stay linear; named jumps, guards, and routine branches belong in graph definitions. - Known limitation: the history timeline is unbounded in 1.0. Long-lived journeys accumulate one
entry per navigation;
restart()is the reset lever, and amaxHistorybound is planned post-1.0 as a compatible addition.
Graph events
Section titled “Graph events”- Define custom events as a discriminated union of
{ type; payload? }values. Call graphsend(type, payload?); payload presence and type are inferred from the selected union member. - Declare ordered transition candidates per event. Synchronous
when({ context, handlers })guards select the first enabled candidate. Guards deliberately receive no event payload because they are also used to derive enabled events; asynchronousonTransitionruns after commit and receives the causing event. - Allow the definition’s handler object to be replaced at machine creation through
createGraphJourney(definition, { handlers }), so one definition can use production or test dependencies. This is a complete override, not a shallow merge. - Return
no-enabled-transitionwhen an event has no matching enabled candidate. Self-transitions remain valid graph transitions and perform a real leave/re-entry.
Plugins
Section titled “Plugins”- Replace intercepting controller plugins with observe-only
JourneyPlugininstances. Each plugin receives a read-onlyPluginHostinsetup()and may expose a namespaced API atmachine.plugins[name]plus derived state atsnapshot.plugins[name]. - Scope mutable built-in plugin state to each
setup()call, so reusing a plugin instance across multiple machines no longer shares replay buffers, timers, analytics events, or subscriptions. - Rewrite persistence around
{ storage, key, clearOnTerminate?, now? }. It stores status, context, timeline, pointer, and save time and exposesinspectPersistedState,readPersisted, andclearPersisted. The creation-timepersistoption additionally restores: a valid non-terminal record found at creation seeds context, timeline, and position, so the firststart()resumes at the persisted step (explicitstartAtwins; records that no longer match the definition are ignored;restart()always begins fresh). WiringcreatePersistencePluginexplicitly stays save-only — plugins observe and cannot seed the runtime. - Rewrite autosave as a debounced observer with required storage, configurable
context | transition | statustriggers, explicit idle/pending/saving/saved/error state, andflushAutosave,clearAutosave, andreadPersistedAPIs. - Rewrite analytics around a safe
tracksink, optionalonError, customtrackAnalyticsEvent, and a bounded 100-entry success/failure history. - Rewrite diagnostics as cached structural analysis exposed by
getDiagnostics, reporting unreachable steps, shadowed transitions, cycles, and missing terminal paths. Graph checks are explicitly skipped for linear journeys. - Change execution paths from static graph enumeration to observed run history via
getCurrentPathandgetCompletedPaths. - Rewrite replay as a bounded timestamped log of status, transition, context, blocked navigation, and error entries, with optional per-entry snapshots and JSON export.
- Add
@rxova/journey-core/subscription-enhancerfor filtered start, restart, complete, terminate, pause, and resume subscriptions without expanding the base machine surface. - Export focused helper functions and associated types from the plugin subpaths for parsing, serialization, normalization, and diagnostics analysis.
Connectors
Section titled “Connectors”- Add the optional
@rxova/journey-core/connectors/immerentry point. ItsimmerConnectoradapts mutating or replacement Immer producers into ordinary Core context updaters without adding Immer to the main Core entry or installation.
The controller-per-concern engine and duplicated linear/graph derivation code were replaced by one snapshot/event runtime. Current minified+Brotli measurements against the
rc.2baseline are:Every export shrank against the
rc.2baseline, several by more than 60%. Per-export budgets are enforced on every build by thesize-limitentries inpackages/core/package.json, which are the current numbers; the figures originally quoted here wererc.2-era measurements for a set of exports this release no longer ships.Core documentation and runnable examples were rewritten around this final contract and its migration path.
- Replace the all-purpose
-
#152
c805d79- Close four gaps in the V1 type surface. Each would have needed a major release to correct once1.0.0froze the exported types.-
createGraphJourneynow carriesTHandlersin its return type. The declared return omitted the generic, so the annotation won over the widening cast andargs.handlersinside send work resolved tounknown— even though that channel is the only way injected clients reach the work. Handlers supplied on the definition, or overridden at creation, are now typed at the call site. -
Graph journeys can type their completion and termination payloads.
GraphSnapshotalready had the slots; nothing filled them, socontrols.complete(anything)compiled andsnapshot.machine.outcomewasJourneyOutcome<unknown, unknown>. Name the payloads through the definition’s$payloadsphantom carrier, alongside the existing$events:createGraphJourney({steps: { review: {}, done: {} },initial: "review",context: {},transitions: { CONFIRM: { from: "review", to: "done" } },$payloads: {} as { complete: Receipt; terminate: "cancelled" }});JourneyTerminationPayloads,CompletePayloadOf, andTerminatePayloadOfmoved from the linear types to the shared core types, since both tiers name them now. They are re-exported from their previous location, so existing imports keep working. -
normalizeGraphDefinitionis no longer exported. Its return type namedRuntimeStepandRuntimeTransition, which are internal and have no export path, so publishing it would have frozen those shapes into the semver contract. It remains available internally to the factories and the diagnostics plugin. Relatedly,GraphJourneyDefinition.eventWorkis now typedReadonly<Record<string, unknown>>and marked@internal: its keys are a private encoding of the (origin step, event) pair. Pass it back to a factory; never construct or read it. -
machine.pluginsrejects undeclared names on linear journeys.createLinearJourneydefaultedTPluginstoreadonly AnyJourneyPlugin[], which collapsedPluginApisto an index signature accepting any key — somachine.plugins.anyTypoAtAllcompiled clean whenever plugins were omitted or the leading generics were supplied explicitly. It now defaults toreadonly [], matchingcreateGraphJourney.One consequence worth knowing: the creation-time
persistoption registers the persistence plugin at runtime but is not reflected inTPlugins, somachine.plugins.persistenceis not statically reachable through that option. PasscreatePersistencePluginexplicitly when you need the API typed.
-
-
#152
c805d79- Subtract the ways to do one thing. The v1 contract answered “which step am I on” with five structural forms for a transition, four places to put pre-move async, ten entry points and seven plugins. None of that was wrong; all of it was a decision the caller had to make before writing a flow. This release removes the duplicates and keeps one spelling of each.Graph transitions live on the step
Section titled “Graph transitions live on the step”The central
transitionsmap is gone. A step declares its own outgoing moves underon, keyed by event, in one of three forms — a target id, an ordered candidate array, or an object carrying declared async work:steps: {login: { on: { submit: "verify" } },verify: {on: {check: {run: ({ handlers }) => handlers.verify(),/* stages the result */ commit: ({ result, updateContext }) => updateContext((c) => ({ ...c, ok: result.ok })),candidates: [{ to: "done", when: ({ context }) => context.ok }, { to: "verify" }]}}},done: {}}A dangling
fromis now impossible by construction — the step key is the origin.stay(),allowRollback, the nested work-authoring callbacks, and result-carrying guards are removed. Result-carrying guards went for a correctness reason beyond subtraction: they madeoutgoingTransitions[].guardandavailableEventsreport on a result the snapshot did not have, so introspection disagreed with what a send would actually do. Guards are now total functions of context, which is what their documentation always claimed.Types are pinned with a bag, not built with a builder
Section titled “Types are pinned with a bag, not built with a builder”createGraphJourneyBuilderand itsbuild()are replaced bywithGraphTypes<Bag>()andwithLinearTypes<Bag>(), plus the exportedGraphStep<Bag>,GraphDefinition<Bag>andBagtypes for steps authored in their own files. Most definitions need none of it: step ids come from thestepskeys and event names from theonkeys, inferred.These are standalone functions rather than a
.withTypesproperty on each factory. Attaching one is a module-level side effect, and it defeated tree-shaking badly enough that importing onlycreateLinearJourneypulled the entire graph tier into the bundle.One channel for pre-move async
Section titled “One channel for pre-move async”registerNextStepInterceptoris removed;goToNextStep(work?)is the only way into core’s transactional pre-move async.goToPreviousStep(n?)no longer sniffs its argument for work.Creating a journey starts it
Section titled “Creating a journey starts it”autoStartnow defaults totrue. The trade is worth stating plainly: starting happens inside the constructor and the initial entry commits synchronously, so the firststepEnterhas already fired by the time the factory returns. Pass{ autoStart: false }when a subscriber has to see it — it is the subscribe-then-start order, and saying so out loud beats a default that silently assumed it.A plain
Section titled “A plain subscribe, and a smaller snapshot”subscribe, and a smaller snapshotsubscriptions.subscribeSelectoris replaced bysubscriptions.subscribe(listener), a plain per-commit callback. Every non-React caller passed an identity selector; React runs its own selector layer overuseSyncExternalStoreand never needed core’s.snapshot.machinekeeps onlyoutcome. The six fields removed from it each restated something the snapshot already said: five werestatus === x, andisLoadingwas a second computation oftransition.pending. Readsnapshot.statusandsnapshot.transitioninstead — the latter also carriesphase,fromandto, so it says what is in flight rather than only that something is.Three entry points, four plugins
Section titled “Three entry points, four plugins”.,./pluginsand./connectors/immer. Every bundled plugin factory now comes from./plugins; tree-shaking is unchanged, since each is still its own module behind a named export.- Autosave folded into persistence as
debounceMsandsaveOn. It was the same plugin with a timer — same serializer, same adapter contract, same key — so it is a parameter, not a plugin.flushPersisted()cancels the wait and writes now. - Diagnostics became
analyzeStructure(definition)on the root entry. Checking a definition never needed a runtime; as a plugin it made you create a machine to ask a question about the definition you already had. - The subscription-enhancer and
./convertentry points are removed, along with the headless usage pattern andPluginHost.onStepEnter/onStepLeave.
- Autosave folded into persistence as
Minor Changes
Section titled “Minor Changes”-
#152
c805d79- AddJourneyError, so failures can be handled by code rather than by message text.Every error Core throws itself was a bare
Errorwith a prose message and no structure — the offending step id existed only inside the interpolated string. Telling “duplicate plugin name” apart from “unknown step in transition” meant matching on that text, which quietly made every message a compatibility promise. Doing this before1.0is what avoids inheriting that promise.import { createLinearJourney, isJourneyError } from "@rxova/journey-core";try {createLinearJourney(definition, { startAt: idFromRoute });} catch (error) {if (isJourneyError(error) && error.code === "unknown-step") {redirectToFirstStep(error.stepId);}}JourneyErrorextendsError, is named"JourneyError", and keeps the existingjourney:message prefix, so anything currently matching on that text still works. It adds:code— a closed union:empty-definition,duplicate-step-id,unknown-step,unknown-initial-step,dangling-transition,duplicate-plugin-name,storage-unavailable,async-commit. Covered by semver; adding a member is a minor change.stepId,event,pluginName— the offender, where one applies.isJourneyError(value)— a narrowing helper, so consumers need not import the class.
Every throw site is converted: both factories, the builder, the converter, persistence storage resolution, and the runtime’s unknown-step, duplicate-plugin, and async-commit guards.
NavigationResult.errorand theerrorsubscription event deliberately stayunknown. They carry whatever the caller’s own navigation work or hooks threw, which Core cannot constrain — wrapping it would hide the original. Error messages remain outside the stability contract: match oncode. -
#152
c805d79- Core owns all linear-tier semantics (RFC 0001 §3.12): new creation optionsstartAt(start directly at a step — earlier steps are neither entered nor visited; unknown ids throw) andpersist({ key, storage? }, expanding to the persistence plugin with a guardedlocalStoragedefault); thestepEnterevent payload now carries an intent-baseddirection("forward" | "backward" | "jump"); linear machines gainmachine.navigate.goToStepByIndex(index). -
#152
c805d79- Persistence reports write failures, and its parser is now total.The “saved” indicator no longer lies.
lastWrittenwas assigned before the write was attempted, so aQuotaExceededError— or any failing adapter — leftinspectPersistedState()returning a record that never reached storage whilelastSavedAtadvanced. A UI bound to that showed “Saved” as data was silently dropped. State now moves only on a confirmed write, and the plugin exposesgetPersistenceState(): { lastSavedAt, error }, mirroringAutosaveState. The snapshot slice atsnapshot.plugins.persistencegains the sameerrorfield.Plugins can report their own asynchronous failures.
PluginHostgainsreportError(error), which routes to the machine’sonListenerError. A tap that throws synchronously was already isolated and reported, but work outliving the tap — an awaited storage write, a debounced flush — had to choose between an unhandled rejection and aconsole.errorthat ignored the configured reporter. Persistence now uses it, so async write failures reach the same place as every other subscriber failure. Adding a host tap is a compatible change under the plugin contract.parsePersistedStatevalidates everything it claims to. It checked thatstatuswas a string, not that it was a real lifecycle status, and thattimelinewas an array, not that it held strings — while typing the result asJourneyPersistedStateand handing it to callers through the publicreadPersisted(). It now checks the status against the known set, requires string timeline entries, requires an integercurrentIndexand a finitesavedAt, and returns a rebuilt record rather than the parsed object.Restored contexts are scrubbed of prototype-poisoning keys.
JSON.parsecreates__proto__as an ordinary own property, so a parsed payload is safe in isolation — but stops being safe the moment application code spreads orObject.assigns it, which copies the own key as a prototype assignment. Storage is attacker-reachable, so__proto__,constructor, andprototypeare now dropped from restored context values at every depth.A
validate/migratecallback for versioning persisted shapes is not included: it is a public API addition that deserves a deliberate design pass rather than being folded into a hardening change.Size cost, minified+Brotli:
createPersistencePlugin+154 B. The factories grew too —createLinearJourney+161 B,createGraphJourney+117 B — because both importreadRestorableStatestatically, so the parser ships whether or notpersistis used. Budgets were raised to match; that is the deliberate price of validating attacker-reachable input. -
#152
c805d79- Harden the three plugin boundaries the runtime did not isolate.A throwing
setup()no longer strands the plugins registered before it. Plugin setup ran unguarded, so a failure part-way through the tuple left earlier plugins already subscribed and holdingonDisposecallbacks — while the machine was never returned, makingdispose()unreachable and their timers and subscriptions permanent. Construction still fails, but teardown now runs first. The same applies when a duplicate plugin name is rejected.A throwing
deriveSnapshotno longer bricks the machine. Derivers run on every publish and in the constructor, and were the only plugin entry point with no isolation — one bad third-party plugin took down every transition. Failures now route throughonListenerErrorlike any other plugin tap, and the plugin’s previous snapshot slice is carried forward so consumers readingsnapshot.plugins[name]do not see it blink toundefined. Other plugins’ slices are unaffected.createExecutionPathsPluginis bounded.completedPathswas the one plugin buffer with no cap: a machine that completes and restarts on a loop retained one frozen array per run for the lifetime of the process. It now takesmaxPaths(default 50, newest kept) and exposesclearCompletedPaths().getCurrentPath()is still unbounded within a single run, matching the history timeline’s documented 1.0 behaviour.Blocked
localStorageaccess reports as a journey error. ReadingglobalThis.localStoragecan throw rather than returnundefined— a third-party iframe with storage blocked, or Safari’s Lockdown Mode — which surfaced as a rawSecurityErrorout ofcreateLinearJourneyand read as a library crash. It is now ajourney:error naming the fix, carrying the original ascause. Persistence still fails loudly rather than silently disabling itself, since a silent downgrade loses data with no signal.The two isolation guards sit on the core path, so both factories grew slightly:
createLinearJourneyby 23 B andcreateGraphJourneyby 38 B minified+Brotli. Their size budgets moved to 5.7 kB and 5.9 kB. -
#152
3893a1b-toSerializableis now linear in the number of distinct objects, and stack-safe.The previous walk removed each node from its
seenset on the way back up. That is correct for cycles, but it meant a shared subtree was re-traversed once per path reaching it — so a context with diamond-shaped sharing, which is routine for normalized or relational data, cost 2^N. The replay plugin serializes the entire snapshot on every transition, status change, context change, blocked navigation, and error, withcaptureSnapshotsdefaulting totrue, so this ran on the hot path and could freeze the event loop.Measured on a diamond-shared structure, before → after:
Depth Before After 14 21 ms 0.22 ms 18 241 ms 0.06 ms 20 932 ms 0.05 ms 26 not in 120s 0.06 ms 40 infeasible 0.09 ms Two correctness fixes came with it:
- Arrays are cycle-tracked. They were matched before the object branch and never entered
seen, so a self-referencing array recursed until the stack gave out. It now yields"[circular]"like any other cycle. - Depth is capped, at 100 by default and configurable per call. A long parent/child chain used to
overflow the stack, and the resulting
RangeErrorwas swallowed by listener isolation — the replay entry vanished with no signal. Nesting past the cap now serializes as"[max-depth]".
toSerializable’s second parameter changes from an internalWeakSetaccumulator to an options object ({ maxDepth? }). Callers passing only a value — every documented use — are unaffected. - Arrays are cycle-tracked. They were matched before the object branch and never entered
-
#152
c805d79- Type-surface and ergonomics fixes that are cheapest while the contract is still open.A rejected navigation now says which target it rejected.
NavigationResult’s failure arm carried only{ ok, reason, error? }, so a caller awaitingsend()orgoToStepById()had to subscribe tonavigationBlockedseparately just to log the attempted step. It now includesfromandto;toisnullwhere no target was ever resolved, such as a graph event with no enabled candidate. Additive — existing checks onok,reason, anderrorare unaffected.The type bag’s
metaandhandlersare inferred from optional properties. The constraint declared them optional butMetaOf/HandlersOfmatched a required property, so anyone who mirrored the constraint and wrotemeta?: MyMetasilently gotRecord<string, unknown>instead of their own type — and the eventual error pointed nowhere near the bag declaration.linearToGraphDefinitionkeeps step-id and event typing, and rejects duplicates. It hard-codedTStepIdtostring, so a converted definition lostgoToStepByIdtyping entirely. It is now generic over the step ids and returns a typedLinearGraphEvent<TStepId>union (NEXT|PREVIOUS|GO_TO_<ID>). It also had no duplicate-id guard, unlikecreateLinearJourney— so a round trip turned a definition that would have thrown into a silently different, cyclic graph (["a","b","a"]becamea <-> b). It now throwsduplicate-step-id.The compilation
libmoves to ES2022 (emit target stays ES2020). Journey targets evergreen browsers and Node >= 20.11, all of which have hadObject.hasOwnandError’scausesince 2021 — without this, each use needed a workaround.JourneyErrornow takes an optionalcausethrough the standard constructor, so a blocked-storage failure keeps the underlyingSecurityErrorattached non-enumerably rather than as an ordinary property. -
#152
c805d79- Close the remaining plugin-boundary gaps, and make the companion types nameable.A shared plugin instance now warns in development. Mutable plugin state is scoped per
setup(), butoptionsis not — attaching onecreatePersistencePlugininstance to two machines meant both wrote the same storage key and silently overwrote each other. Each instance now warns from its secondsetup(). State was already isolated; only the configuration was shared.clearPersisted()andclearAutosave()contain storage failures. Both calledremoveItemunwrapped, so a throwing adapter propagated to the caller — inconsistent with their sibling writes, which are all contained and recorded. They now record the failure in the plugin’s error state.A throwing analytics
onErrorno longer escapestrackSafely. It sat outside the guard, so “the sink failed” and “your error handler failed” were indistinguishable at the isolation boundary.Companion types are exported. The type bag exists so steps and hooks can live in separate files, which only works if the types their signatures mention are nameable. Added to the root entry:
BagSendWorkArgs,BagSnapshot,GuardArgsOf,HandlersOf,JourneyEventWork,MetaOf,StayFactory,ToFactory,WorkFactory,WorkGuardArgs,SendArgs,SendVerb,SendWork,SendWorkArgs,CompletePayloadOf,TerminatePayloadOf,JourneySnapshotBase, andJourneyStorage(named by the already-exportedJourneyPersistOption).JourneyStepConfigreplaces reaching throughJourneyStepBuilder["_config"]. That member was required for the advertised multi-file authoring pattern, so an underscore-prefixed internal had become part of everyday use. It is now named, and_configis marked@internal.The step-id inference trap is documented. Hoisting
stepsout of the call — the common tidy-up — widens the array tostring[]and silently collapsesTStepIdtostring, losinggoToStepByIdtyping,startAtvalidation, and the React tier’sviewsexhaustiveness all at once, with no diagnostic. The TypeScript guide now covers it and the fix.Not included: strict structural validation in the graph factory. The diagnostics plugin already reports unreachable steps, shadowed transitions, cycles, and missing terminal paths, and duplicating that analysis on the creation path would add bytes to every consumer for something an opt-in plugin does more thoroughly.
Size: the dev warnings pull
@rxova/journey-common/devinto the persistence and autosave entries (+111 B and +129 B); those are opt-in subpaths, so only their users pay. -
#152
c805d79- Name an edge withlabel, bound one edge’s async withtimeoutMs, and infer a work entry’s result withdefineWork.Three gaps, all of them things a graph could express before the type bag replaced the builder.
Section titled “label on a candidate”labelon a candidateSeveral candidates on one event differ only by guard, so
eventandtodo not say which one fired. A priority index says where an edge sits, not what it is.on: {PAY: [{ to: "review", label: "needs-review", when: ({ context }) => context.tier === "free" },{ to: "review", label: "flagged", when: ({ context }) => context.flagged },{ to: "done", label: "straight-through" }];}The name then appears in timeout and error messages (
onTransition(needs-review) timed out after 5000ms), in the newtransitionargument every step hook receives, and in the structure view plugins andanalyzeStructureread. Labels stay optional: an unlabelled edge is described by its declaration index instead —PAY[1] (checkout -> review)— and reportslabel: nullalongside itsindex.StepHookArgsgainstransition: TransitionInfo | null, carrying{ event, from, to, label, index }on hooks that ran for an edge andnullfor the initial entry, timeline moves, and linear navigation.JourneyStructure.transitionsgainslabelandindex.
Section titled “timeoutMs on a candidate, a work entry, and navigation work”timeoutMson a candidate, a work entry, and navigation workdefaultTimeoutMswas the only dial, so one edge calling a slow third party forced every other edge onto the slow one’s budget. Each edge can now declare its own, falling back to the global when it does not:const machine = createGraphJourney(definition, { defaultTimeoutMs: 2_000 });// ...in the definition:on: {SUBMIT: {run: ({ handlers }) => handlers.creditCheck(),label: "credit-check",timeoutMs: 30_000,candidates: [{ to: "approved" }, { to: "declined" }]}}On a work entry it bounds
run; on a candidate it bounds that candidate’sonTransition;NavigationWorktakes it too, sogoToNextStep({ run, timeoutMs })works the same way. Both new fields are validated when the definition is built rather than when the timer first matters, under the newinvalid-labelandinvalid-timeouterror codes.
Section titled “defineWork — the run result without restating it”defineWork— the run result without restating itA declared
runsits at a property position, which is not an inference site, socommit’sresultwasunknownunless the bag’sresultspinned it. A generic function call is an inference site:verify: defineWork<AuthBag, "verify">()({run: ({ handlers }) => handlers.verify(), // the result type comes from here/* result is typed by `run` above */ commit: ({ result, updateContext }) =>updateContext((context) => ({ ...context, ok: result.ok })),candidates: [{ to: "done", label: "verified", when: ({ context }) => context.ok },{ to: "twofa", label: "retry" }]});commitgets a typedresult, andrun’seventis narrowed to the key the entry is declared under. The call is curried because TypeScript infers all of a call’s type arguments or none: pinning the bag and event inline would opt the result type out of inference, which is the problem being solved.Guards are untouched and stay total functions of context — the run result reaches them only through what
commitstages. Result-carrying guards were removed in the v2 subtraction for a correctness reason that still holds: the same guards run during snapshot derivation, where no send is in flight, soavailableEventswould disagree with what a send actually does.resultson the bag still works and is unchanged;defineWorkis the alternative for anyone who would rather not keep a second declaration in sync.
Patch Changes
Section titled “Patch Changes”-
#152
c805d79- The persistence plugin no longer leaks an unhandled rejection when an async storage adapter fails.JourneyStorage.setItemis declared asvoid | Promise<void>so adapters can be asynchronous, but the plugin discarded the returned promise withvoid— a rejecting adapter therefore produced an unhandled rejection, which terminates the process under Node’s default--unhandled-rejections=throw.The write is now contained and routed to the listener-error reporter, matching how a synchronous
setItemthrow was already isolated. Autosave was never affected: it awaits inside atry/catchand surfaces failures through its own state.Reporting is still coarse — persistence has no error channel of its own, so a failed write is observable only through the reporter, and
lastSavedAtcontinues to advance. A dedicated persistence error state is planned separately. -
#152
c805d79- The snapshot’scontextis now shallow-frozen in development, matching every other snapshot slice. Mutating a context in place changes nothing the machine can observe — no publish, no subscriber notification, no re-render — so the bug was silent; it now throws where it happens.Shallow on purpose: deep-freezing would cost a full walk per update and break Maps, Dates, and class instances that legitimately live in a context. Production behaviour is unchanged.
-
#152
c805d79- Step-id validation now tests own properties instead of usingin, which walks the prototype chain."toString","constructor","__proto__","hasOwnProperty","valueOf","isPrototypeOf","propertyIsEnumerable", and"toLocaleString"passed every guard that compared an id against the steps record, producing a machine parked on a step that does not exist.The visible failure was a phantom position:
goToStepById("hasOwnProperty")returned{ ok: true }withcurrentStep.index === -1, so every order-derived snapshot field (index,isFirstStep,isLastStep) lied, andgoToNextStep()from there resolvedindexOf(...) === -1to index0— a “Next” button that silently rewound the journey to step one. The phantom entry also stayed in the timeline permanently.Two of the nine affected guards sit on input the application does not author: the persisted-record predicate behind the creation-time
persistoption, andgoToStepById, which is routinely fed a route parameter. A tampered or drifted storage record could therefore restore onto a phantom step even thoughreadRestorableStatedocuments that definition drift is rejected.Steps legitimately named after a prototype key keep working — they are own properties, so they were never the problem.
-
#152
c805d79- Fix type resolution for consumers onmoduleResolution: "node16"/"nodenext". The published.d.tsand.d.ctsfiles carried extensionless relative imports (./helpers,../core/types), which those resolvers cannot follow — every entrypoint reported an internal resolution error. Bundler and CJS consumers were unaffected, which is why it went unnoticed.The published declarations now carry explicit
.jsextensions, added at build time bycopy-types.tsrather than written by hand, so source keeps its extensionless imports. The rewrite resolves each specifier against the emitted declarations and throws if one does not resolve, so a future directory import or dynamicimport()type cannot silently reintroduce the bug.The
attwscript that would have caught this was declared but never installed or run in CI;@arethetypeswrong/cliis now a real dependency andpackaging:checkruns it. -
#131
5a7c344- Broaden the npm keywordsRegistry metadata is read far more often than it is written, and it was missing the words people search for. Adds
multi-step-form,multi-step,onboardingandcheckout-flow— what the problem is called by somebody who has it and does not yet know this exists — plus the parts of the model that are the reason to choose this over an array and a pointer:branching,guards,transitions,history,time-travel.framework-agnostic,vanilla-jsandzero-dependencysay what makes it usable outside React at all. No code changes — a patch release is only how the new metadata reaches npm.
1.0.0-rc.3
Section titled “1.0.0-rc.3”Patch Changes
Section titled “Patch Changes”-
#131
5a7c344- Broaden the npm keywordsRegistry metadata is read far more often than it is written, and it was missing the words people search for. Adds
multi-step-form,multi-step,onboardingandcheckout-flow— what the problem is called by somebody who has it and does not yet know this exists — plus the parts of the model that are the reason to choose this over an array and a pointer:branching,guards,transitions,history,time-travel.framework-agnostic,vanilla-jsandzero-dependencysay what makes it usable outside React at all. No code changes — a patch release is only how the new metadata reaches npm.
1.0.0-rc.2
Section titled “1.0.0-rc.2”Patch Changes
Section titled “Patch Changes”- 5bc391a: Add
journey.reset/subscribeReset, and simplify examples and docs around machine-wide lifecycle observation. - 4a16dd2: Rename the machine startup API from
start()tostartJourney(). - a558001: Improve graph builder transition typing.
- 87a83d7: Make transition callback context readonly.
- 882d5a5: Expose builder event metadata for step-scoped sends.
- b95191f: Run terminal transition lifecycle callbacks before completing or terminating.
- ada8084: Treat global transitions as fallbacks after step-local transitions.
- 29f008d: Replace user-authored transition ids with internal ids and labels.
1.0.0-rc.1
Section titled “1.0.0-rc.1”Major Changes
Section titled “Major Changes”-
1cdde02: ## Breaking changes
Section titled “JourneyDefinition simplified from 6 to 4 generic parameters”JourneyDefinitionsimplified from 6 to 4 generic parametersThe separate event-type and payload-map generics are now represented by a single
TEventMaprecord, and the extra step generic has been removed.Declarative transitions replace the old builder exports
Section titled “Declarative transitions replace the old builder exports”tx(),createTransitions(), andcreateTypedTransitionHelpers()are gone from the public API. Transitions now use declarative graph syntax or linear syntax.Explicit machine lifecycle with
Section titled “Explicit machine lifecycle with machine.startJourney()”machine.startJourney()Machines are created in the
"idled"state and must be started explicitly.resetJourney()returns the machine to"idled".Renamed public API surface
Section titled “Renamed public API surface”createMachine()->createJourneyMachine()resetMachine()->resetJourney()Machine->JourneyMachine
Core features extracted into plugins
Section titled “Core features extracted into plugins”Persistence and execution-path enumeration are no longer built into the base machine. Register plugins explicitly instead.
Exported runtime constants removed
Section titled “Exported runtime constants removed”JOURNEY_STATUS,JOURNEY_EVENT,JOURNEY_ASYNC_PHASE, andJOURNEY_WILDCARDare no longer exported. Use the corresponding string literal types instead.Status and observation event names aligned to past tense
Section titled “Status and observation event names aligned to past tense”Status values now use names such as
"completed"and"terminated".
Section titled “createJourneyBuilder”createJourneyBuilderA per-step graph builder API that compiles to the same
JourneyDefinitionaccepted bycreateJourneyMachine()andcreateJourney().First-party plugins
Section titled “First-party plugins”- Analytics
- Autosave
- Diagnostics
- Execution paths
- Persistence
- Replay
Section titled “goToStepById is now mode-aware”goToStepByIdis now mode-awareIn headless mode it performs caller-driven navigation. In graph or linear mode it follows declared transitions like any other event.
Section titled “onEnter / onLeave lifecycle callbacks”onEnter/onLeavelifecycle callbacksStep definitions can now declare observational enter/leave callbacks.
Section titled “timeoutMs on transitions”timeoutMson transitionsAsync guards and effects can now time out independently, fail cleanly, and emit the normal transition failure path.
Changed
Section titled “Changed”updateContextQueued()has been removed; useupdateContext()instead- Internal machine logic was split into smaller runtime, navigation, async-state, control, and send modules
Minor Changes
Section titled “Minor Changes”-
239f7c5: ## What changed
- Async lifecycle handling is much safer. Resetting or disposing a machine now cancels stale guards and effects, so older async work cannot commit after the machine has already moved on.
send()and the convenience helpers no longer reject when a guard or effect fails. They resolve withtransitioned: false, the currentsnapshot, and anerrorfield, while still emittingtransition.errorand leaving the source step in asyncerror.goToStepByIdis now mode-aware and lifecycle-aware. In graph and linear definitions it follows only declaredgoToStepByIdtransitions, including guards and effects. In headless definitions, omittingtransitionsrestores caller-driven direct navigation.- Observability is broader and easier to use. The core machine now exposes
subscribeSelector, exportedJourneySelector/JourneyEqualityFn, and focused lifecycle helperssubscribeStart,subscribeComplete, andsubscribeTerminate. - A new
journey.startlifecycle event is emitted on startup, and focused helpers (subscribeStart,subscribeComplete,subscribeTerminate) make lifecycle observation easier without broad event filtering. - Persistence was hardened for hostile browser environments. If default storage access throws, machine creation no longer fails, and hydrated machines now preserve
visitedstate inferred from timeline history instead of losing it during restore. - The reserved wildcard step id
*is now rejected as a real step name, preventing ambiguous behavior between step identifiers and wildcard transition matching. - Transition typing was improved substantially. Builders now support fluent
.when(...).to(...)and.otherwise().to(...)branches, payload inference is sharper, and terminal/helper transitions are typed more precisely. - The existing “auto-complete when there is no next-step transition” behavior is now configurable through
requireExplicitCompletion, rather than being effectively fixed at the runtime level. - Docs and tests were expanded around async timing, terminal helpers, persistence hydration, selector subscriptions, and the caveat that
updateContext()is immediate on the current snapshot but does not retroactively rebase an already-running async transition.
Breaking changes
Section titled “Breaking changes”goToStepByIdis no longer one unconditional behavior across every mode. Omitted-transition definitions stay caller-driven, while graph and linear definitions require declaredgoToStepByIdtransitions.- TypeScript transition definitions are stricter. Consumers with loosely typed transition builders or payload assumptions may need source updates.
Patch Changes
Section titled “Patch Changes”- 4ee201f: Per-package patch notes:
@rxova/journey-devtools-bridge- Guarded bridge transport posting with a safe
try/catchsowindow.postMessagefailures are swallowed. - Prevents bridge lifecycle/command flows from throwing when browser messaging is unavailable or rejects.
- Guarded bridge transport posting with a safe
@rxova/journey-react- Memoized provider context value in
Providerto keep stable references whenmachine/journeyinputs are unchanged. - Reduces unnecessary rerenders for memoized consumers during unrelated parent rerenders and StrictMode churn.
- Memoized provider context value in
@rxova/journey-core- Added listener-churn edge coverage to verify snapshot/event subscriptions are fully removed after unsubscribe.
- Hardens regression protection around subscription retention behavior.
Patch Changes
Section titled “Patch Changes”-
99a6635: Added a new public API TSDoc quality gate (docs:api:check) that verifies callable exports from package entrypoints have TSDoc summaries.
- Enforced that check in CI/docs workflows and documented it in contributor/docs guides.
- Added the checker implementation and comprehensive tests for pass/fail/CLI behavior.
- Added/updated TSDoc on key public exports:
- core transition builders (tx, createTransitions)
- react bindings factory (createJourneyBindings)
- devtools bridge attach + protocol envelope/command validators
- No runtime behavior changes; this branch is primarily API documentation quality/tooling hardening.
@rxova/journey-core
- Added TSDoc summaries for public transition helpers (tx, createTransitions).
- Added tests for the new API TSDoc checker (check-public-api-tsdoc) under core tests.
- No runtime behavior changes.
@rxova/journey-react
- Added a TSDoc summary for createJourneyBindings (public React API entrypoint helper).
- No runtime behavior changes.
@rxova/journey-devtools-bridge
- Added TSDoc summaries for public bridge/protocol APIs (attachJourneyDevtools and envelope/command validators).
- No runtime behavior changes.
apps-docs
- Documented the new API docs quality gate (pnpm run docs:api:check) in the docs README.
- No end-user docs content changes beyond contributor/developer guidance.
repo/tooling (cross-package)
- Added docs:api:check script to root package.json.
- Added scripts/check-public-api-tsdoc.ts to enforce TSDoc coverage on public callable exports.
- Wired this check into CI/docs workflows and contributing guidelines.
Patch Changes
Section titled “Patch Changes”- 6a38c50: - Tightened core machine typing by introducing JourneySendEvent and removing unsafe as unknown/as never casts in convenience APIs.
- Replaced JourneyStepDefinition’s open
Record<string, unknown>escape hatch with explicit typed step extensions. - Added runtime validation in devtools bridge for command stepId values (goToStepById, updateStepMetadata, clearStepError), returning commandError for unknown steps.
- Added/updated tests for type coverage and bridge invalid-step behavior.
- Added JSDoc to key public core types (including transition/event builder generics) and improved type readability with JourneyGoToStepByIdEventType.
- Replaced JourneyStepDefinition’s open
Patch Changes
Section titled “Patch Changes”- 7be5e0c: minor updates
- adds shell header + set -e to Husky hooks,
- fixes test fixture newline escaping,
- adds explanatory comment before “use client”,
- tiny docs visual tweak.
Minor Changes
Section titled “Minor Changes”- 56234c2: Improve docs across Core, React, and Devtool Bridge, including API restructuring, clearer runtime semantics references, and TypeScript-focused guidance.
Minor Changes
Section titled “Minor Changes”-
16db5e3: Journey 0.5.0 is a full platform-level upgrade across core runtime, React bindings, and devtools. This 0.5.0 release focuses on deterministic flow behavior, stronger typing, cleaner APIs, and better observability/debuggability.
Section titled “@rxova/journey-core”@rxova/journey-coreNew and improved
Section titled “New and improved”- New canonical snapshot shape with
history.timeline+history.indexpointer model. - Deterministic pointer navigation APIs:
goToPreviousStep(steps?),goToLastVisitedStep(). - Convenience helpers:
goToNextStep(),completeJourney(payload?),terminateJourney(payload?). - Built-in fallback semantics for
back/goToPreviousStepevent sends when no explicit transition matches. - Strongly typed transition builder ergonomics via
createTransitionsandtxhelpers (toComplete,toTerminate, branching builders). - First-match-wins transition execution preserved and clarified for reliability.
- Typed async transition phases exposed in snapshot:
idle,evaluating-when,error. - Metadata is now first-class at runtime via
snapshot.stepMetaandupdateStepMetadata(stepId, updater). - Typed observability stream via
subscribeEvent(...)with lifecycle/navigation/metadata events. - Expanded persistence model with versioning/migration support and safer hydration of invalid data.
Breaking changes
Section titled “Breaking changes”- v1 top-level
timeline/indexsnapshot fields removed. HISTORY_TARGETremoved.- Legacy history helpers removed (
trimHistory,clearHistory, overflow options). - Persistence now targets v2 snapshot structure and should be migrated with
migrate(...)when needed.
Section titled “@rxova/journey-react”@rxova/journey-reactNew and improved
Section titled “New and improved”- Bindings-first architecture is now the default.
createJourneyBindings(journey)returns typedProvider,StepRenderer,useJourneyApi,useJourneySnapshot, anduseJourneyMachine.- Journey typing is captured once at bindings creation time; hook callsites no longer need per-call generics.
useJourneyApi()now delegates to machine-level navigation helpers (goToNextStep,completeJourney,terminateJourney, pointer APIs).- Imperative jumps remain available using event send:
api.send({ type: "goToStepById", stepId })andapi.send({ type: "goToStepById", stepId, payload }). resetOnJourneyChangebehavior is explicitly supported for intentional machine resets when journey definition identity changes.
Breaking changes
Section titled “Breaking changes”- Legacy global React hooks/components API removed in favor of bindings-first usage.
goToStepById(...)is no longer a dedicateduseJourneyApihelper; useapi.send({ type: "goToStepById", ... }).- Existing apps that called old global hooks/components or helper methods must migrate to bindings APIs.
Section titled “@rxova/journey-devtools-bridge”@rxova/journey-devtools-bridgeNew and improved
Section titled “New and improved”- Protocol remains version
3(no protocol version bump in this release). - Richer command set for runtime control:
goToNextStep,terminateJourney,completeJourney,goToStepById,goToPreviousStep,goToLastVisitedStep,updateStepMetadata,send,resetJourney,clearStepError. - Snapshot payloads now include full v2 runtime state:
currentStepId,history.timeline,history.index,context,visited,stepMeta,status,async. - Safer runtime defaults: bridge enabled by default in non-production; disabled by default in production unless explicitly enabled; commands disabled by default in production unless explicitly enabled.
Breaking changes
Section titled “Breaking changes”- Consumers should align command/snapshot assumptions with current protocol v3 shape.
- Tooling relying on old snapshot/history shape must migrate to
history.timelineandhistory.index.
Migration checklist
Section titled “Migration checklist”- Update core snapshot reads from v1 fields to v2 fields (
snapshot.timeline->snapshot.history.timeline,snapshot.index->snapshot.history.index). - Replace removed history APIs (
trimHistory,clearHistory, overflow options) with pointer navigation APIs. - Migrate persisted snapshots to v2 shape (or provide
persistence.migrate). - Move React usage to bindings-first patterns (
createJourneyBindings+ bound hooks/components). - Replace
api.goToStepById(...)calls withapi.send({ type: "goToStepById", ... }). - Update devtools integrations to current protocol v3 command/snapshot structures.
- This release is intentionally comprehensive and includes updated docs, examples, devtools integration notes, and test coverage for the new model.
- Package versions are set to
minorso the fixed published package group bumps from0.4.0to0.5.0. - App package versions are also aligned to
0.5.0forapps-docsandapps-devtools.
- New canonical snapshot shape with
Minor Changes
Section titled “Minor Changes”- a3a8ea0: fix: keep visited independent of history trimming and persist it across hydrates
Minor Changes
Section titled “Minor Changes”- 9cb812c: # Add history management and trimming controls
- Core:
historyoptions withmaxHistory,onOverflow, and manualtrimHistory/clearHistory. - React: pass
historyoptions through<JourneyProvider>and expose trim/clear inuseJourneyAPI. - Docs: clarify history/visited behavior and overflow reasons.
- Core: