Yoltra 0.10 release notes
#0.10.0 (2026-10-03)
#@yoltra/core
Features
- A typed array,
DataVieworArrayBufferin state is now one value at its own path, compared by reference likeMapandSet. Replacing a 4-byteUint8Arrayused to report four paths, one per byte, inchangedPaths,prevValuesandnextValues; it now reports the view's path once, and a subscription to an index inside a view is no longer notified. The development warning for a payload kept by reference now says that binary data cannot be frozen, so a later write changes the slice unseen, and it also catches a buffer kept from a field of the payload. store.call()takescancel: [channel, type], typed to events whose payload accepts aCallCancellation(new exportsCallCancellation,CancelKey). When the call is cancelled, aborted by its signal, or times out, it emits that event with{ requestId, reason, detail? }so the responder can stop working; never after a terminal reply, never on store disposal, never for a request that was not sent, and never throwing.meta.correlationIdcarries over when set.store.call()takescorrelation: "either" | "causal" | "id"(new typeCallCorrelation)."either"is the default and unchanged: a reply matches by its parent link or an echoedcorrelationId."id"matches on the echoed id alone, for a responder whose protocol carries its own request id and keeps several requests in flight on one channel, where a reply can descend from the wrong request and used to settle it."causal"ignores an echoed id."id"without acorrelationId, or an unknown mode, throws when the call is made, before anything is emitted.createStoretakesclockandscheduler(new exportsClock,Scheduler,TimerHandle): the store reads the time and arms every timer through them, for deduplication windows, the dedup cache prune (now a self-rearming timeout) and the idle timeout ofstore.call().persisttakes ascheduleroption. The defaults look the globals up at each use, so fake timers installed after a store was built still apply.InstrumentedEventgainsat, the clock time the event was processed, and itseventcarriesparentIdanddepth. A payload too large to fingerprint is still never deduplicated, but no longer leaves an unmatchable entry in the dedup cache.- One diagnostics seam.
createStoretakesdiagnostics, aDiagnosticSink(new exportsDiagnostic,DiagnosticCode,DiagnosticSink), and stores gainonDiagnostic(observer). Every failure the store contains (effect, reducer, subscriber, connect handler, middleware, observer), every refusal (cascade, rejected write) and every development warning (key collision, payload by reference, dotted key, missing snapshot slice) is reported as aDiagnosticwith a stablecode. The sink replaces the console output; without one the console output is unchanged. Runtime observers are additive and never silence the console. Theon*hooks still fire.EventBusandLooseEventBustake an optional handler-error callback, defaulting to the console. - New
store.instrumentEffects(observer, options?)(withInstrumentedEffects,InstrumentedEffect,EffectsObserver): once every effect for an event has settled, reports each effect's name, origin, duration and whether it failed, plus the whole phase's duration and the clock time. Nothing is timed while no observer is registered; ephemeral events reach only observers that opt in; a throwing observer is reported asobserver-error. - Effects receive a fourth argument, an
EffectContext(new export), whosesignalaborts when the effect stops being registered: its disposer runs,replaceEffectsorhotReplaceremoves it ("effect replaced"), or the store is disposed. Created on first read, one per registration.onEffecthandlers receive it as their fifth argument. Effects written with three parameters are unaffected. createStoretakesephemeral, a list of channels whose events are traffic, not history: they are handled as usual but reach only instrumentation observers registered withstore.instrument(observer, { ephemeral: true })(newInstrumentOptionsexport), cost no instrumentation work while none is, and are skipped by replay. An ephemeral event that writes state is warned about once in development (ephemeral-write).persistopts in. The README documents the pattern for values that change many times a second.- A reducer or an effect registered with
when: { channelPattern }now throws, naming the registration.channelPatternwas only ever honoured by middleware: on a reducer or an effect it was accepted without a word and handled nothing, so the reducer never ran and the effect was not even reported toonRegistrationChange.ReducerSpec.whenandEffectSpec.whenare now typedExactWhen(new export:Whenwithout its pattern form), so the mistake is a compile error too. Awhenof none of the five forms ({},{ any: false },{ keys: "x" }) also throws, on reducers, effects and middleware alike. Every entry point checks the whole batch before changing anything, so a refusedreplaceReducers,replaceEffects,replaceMiddlewareorhotReplaceleaves the store as it was. Breaking for code that registered either form, which never worked: name the channels, or keep the pattern on a middleware. See docs/en/UPGRADE_0.10.md. matchesWhen(when, event)anddescribeWhenProblem(when, consumer)are exported, with theWhenConsumertype: the matcher every seam of the store uses and the check that refuses a malformed matcher, for code that filters events by aWhenoutside the store.matchesWhenreads onlychannelandtype, so an instrumented event'seventis accepted as is.- New
warnOnLargeValues(store, limits?)(withLargeValueLimitsandSizeWatchedStore): a development-only check that warns once per event key and once per slice when a committed payload or a changed slice exceeds a node or estimated byte limit. A separate import, tree-shaken when unused, and a no-op in production. Built oninstrument(..., { ephemeral: true }), with a bounded measurement that stops at the limit. - New
store.metrics()(withStoreMetrics): queue depth, effects in flight, dedup hits and dedup entries, cheap enough to read on every metrics scrape. Newstore.whenIdle(): resolves when no event waits to be reduced and no effect runs, for a graceful shutdown; a call waiting for its reply and pending timers do not delay it, and every wait resolves on dispose. persistnever writes a partial state. State past the node budget (PersistOptions.maxNodes, new, default 100 000) is not written, storage keeps its previous value, andonErrorreceives aPersistEncodeError(new export) withtruncated: trueandwritten: false;dehydratereturns""in that case. Unsupported values are still written and reported as aPersistEncodeErrorwithwritten: true.persistalso writes only after an event that changed state, not after a vetoed, refused or no-op one.- The function
persist()returns now returns a promise that resolves once the last write has settled, so the final write can be awaited before the store is torn down; it never rejects, and a failed write still goes toonError. replaceReducersandhotReplace({ reducer })now takeReducerReplacement(new export): every slice optional, each typed with its own slice's state. The argument used to require every slice name and type each reducer with the union of all slice states, so an annotated reducer failed on any store with two slices, a reducer could return another slice's state, and on a decorated store the only call that compiled named a slice mounted at runtime, which the runtime refuses.StoreSpec.reduceris typed per slice the same way.EventFromWhengains itschannelPatternarm, resolving to the whole event union a middleware receives instead ofnever.- A disposed store is inert:
emit()resolves{ committed: false }without running anything,call()rejects withCallAbortedError("store disposed")as does every call still pending, andregister*,with*,onEffect,replace*andhotReplacethrow naming the store. A lateemitorcallis reported once per method in development asuse-after-dispose.dispose()is idempotent. Newstore.signal, anAbortSignalcreated on first read and aborted last indispose(). Acall()whose signal is already aborted no longer sends its request or arms a timer.
Fixes
- The error for an effect registered with
channelPatternsays "an effect takes", not "a effect takes". - The development warning for two
(channel, type)pairs that join to one internal key is now remembered per store and names the store. It was remembered for the whole process, which got both directions wrong: once one store had reported a key, a second store with the same collision stayed silent, and two stores that each used one of the pairs, which cannot interfere, were reported as colliding. - The development warning for a state key containing a dot is kept per store and names the store and the slice; it used to be kept for the whole process, so one store reporting a key silenced every other. The entity adapter keeps its dotted-id warning per adapter for the same reason.
EventFromWhen,ReducersMapAny,StateFromReducersandEMFromReducersStrict, already exported, are now documented in the API reference.Storegains a class description,LooseEventBusgets its description back, andInstrumentedEvent.reduceTimeMsis described as the monotonic duration it is. The README says that effects for one event run in sequence and thatemit()waits for them, and gives current figures for the cost of diffing an array against a normalised collection.
#@yoltra/devtools-browser-agent
Features
- Several stores in one page work through the extension's bridge. Each
postMessagesocket stamps its frames with its ownconnectionid, accepts only frames to the page that carry its id (or none), and posts aclosednotice when it closes;BridgeMessagegains the optionalconnectionandclosedfields. Before, every store on a page reached the panel as one connection: only the first store registered, and a command for one store, time travel included, was applied by all of them.
#@yoltra/devtools-cli
Features
--token <secret>and theYOLTRA_DEVTOOLS_TOKENenvironment variable (exported asTOKEN_ENV;parseArgstakes the environment as an optional second argument): the embedded hub requires the token and the terminal panel presents it. In the Emit tab,q,[,], Tab andrno longer act while a field has focus, so they can be typed into a channel, a type or a payload; Esc leaves the form and Enter returns to it.
#@yoltra/devtools-protocol
Features
duplicateStoreIdError(storeId): the handshake error every hub uses to refuse a store whose id is already connected, so the refusal reads the same over a socket and through an in-memory broker.
#@yoltra/devtools-server
Features
- The standalone CLI takes the hub's token from
--token <secret>or theYOLTRA_DEVTOOLS_TOKENenvironment variable (the flag wins), and refuses--tokenwithout a value instead of starting an open hub. A store presenting a store id that is already connected is refused with a handshake error naming the id, instead of silently replacing the first store's registration (after which events from both shared one id, commands reached only the newer store, and either one leaving removed the other). The refusal log reads "Rejected an extension handshake".
#@yoltra/devtools-storeview
Fixes
mountDevtoolsandDevtoolsApppassauthTokento the hub through their config (the hub connection config gained the field), so the panel can connect to a hub started with a token. The README (en, es) says so.- The event status toggle reads Uncommitted instead of Bounced, and the event detail badge reads uncommitted instead of vetoed, matching the store, which reports events that did not commit for reasons other than a veto too.
#@yoltra/devtools-ui
Features
HubConnectionConfig.authToken:HubProvidersends it in the handshake, so a panel can connect to a hub started with a token.useStoreStateno longer freezes when the first snapshot is version 0 (a store that has committed nothing yet): it tracks whether a snapshot has arrived separately from the version, so later patches apply instead of being buffered until the next snapshot.
Fixes
- The loopback hub refuses a second store presenting a connected store id, with the same message as the hub, instead of registering it beside the first.
#@yoltra/react
Fixes
useAtomicProp(slice)called without a path accessor, or with a dotted path where the accessor goes, throws a sentence naming the hook and both calling forms instead of aTypeErrorfrom inside the path recorder. The README says that a wildcard path handsmapthe whole slice.- The Suspense hooks no longer serve a value loaded from state that has since changed. Their cache was invalidated only by the store subscription, which React attaches after commit, so a change landing between a render and that subscription was missed and the stale value stayed on screen indefinitely. Cache entries now record the values they were loaded from, and a read whose values differ loads again;
suspenseCache.readtakes them as an optionalsource. A load overtaken by a newer one no longer overwrites it.