Yoltra 0.8 release notes
#0.8.0 (2026-09-27)
#@yoltra/core
Features
- An in-flight
store.call()now survives a hot reload. The call registers a pattern effect on its reply channel for its lifetime, andreplaceEffectscleared both effect registries wholesale, so saving a file mid-call destroyed the reply listener. The call did not fail: it hung to its idle timeout, with nothing connecting the hang to the file save. The reply listener is now the store's own registration and is preserved even under{ scope: "all" }, because a harness resetting a store never means "and abandon the call that is currently awaiting a reply". encodeStateno longer destroys binary values and exotic prototypes in silence. The walker fell through toObject.entrieswith no prototype guard, so aUint8Arrayencoded as{"0":1,"1":2}, anArrayBufferas{}, and a class instance as a plain object with its prototype gone - none of it reported inEncodeReport.unsupported. Both consumers were affected: persistence encodes on write, and time-travel applies decoded state to the live store. All nine typed arrays plusDataViewandArrayBuffernow round-trip through abinarytag carrying the constructor name, encoded as base64 (one node and about 1.37 JSON characters per byte, against 100,000 nodes and roughly 3.5 for a number array, which is what decides whether a bounded snapshot fits). Decoding resolves the constructor through a frozen null-prototype allow-list read with an own-property check, never a global or a plain object literal:kindarrives off a socket, and a frozen literal still answers"constructor"fromObject.prototype. An unrecognised exotic is now reported and tagged with its constructor name while keeping its own enumerable properties, so the round trip stays exactly as good as it was rather than becomingundefined- a data-loss fix should not lose more data than the bug. A view is encoded as its own window rather than its whole backing buffer, so a decoded view no longer shares a buffer with its former siblings.- Content deduplication fingerprints through the codec instead of
JSON.stringify, which produced two opposite failures on the one path whose whole job is deciding whether two payloads are the same. AMap,Set,ArrayBufferor typed array stringified to{}, so distinct payloads collided and the second event was silently swallowed - precisely what the README says Yoltra refuses to do by default. ABigIntor a cyclic payload threw, hit a timestamp-and-random fallback, and was never deduplicated however identical. Two further collisions are fixed on the primitive fast path:nullandundefinedboth produced::null, andString(payload)made the number1and the string"1"the same event, as it didtrueand"true". Plain-object keys are sorted, so key order is not content; array andMaporder is left alone, because sorting those would make genuinely different payloads collide, which drops real events rather than merely failing to dedupe. A payload past the fingerprint node budget is never deduplicated, the safe direction. The extra walk runs only whendedupWindowMsis greater than zero, which is off by default, and an explicitdedupKeybypasses fingerprinting entirely.{ createStore }grows from 9.0 KB to 10.1 KB gated and 8.3 KB to 9.3 KB shipped, because the codec is now reachable from the store rather than only from persistence and the barrel. emitnow says why an event did not commit.committed: falsearrived from three unrelated causes through one shared frozen object, so a caller could not tell a guard refusing an action from a double-click being collapsed by dedup - and those want opposite responses, since a submit button should surface the refusal and stay silent about the duplicate.EmitResult.reasonis"vetoed","deduped"or"cascade", andEmitResult.vetoedBynames the middleware that refused, from itsmeta.nameor its own function name. That closes an asymmetry: a reducer refusal has always named its slice throughrejectedByandonRejected, while a veto named nobody. Both fields are absent when the event committed, so nothing existing changes shape.- Middleware now vetoes an event only when it returns an explicit
false. The test was!result, soundefined,0,"",nullandNaNall vetoed: middleware that did its work and fell off the end silently swallowed every event it matched, and the symptom - reducers quietly stopping for one channel - looks like a routing,whenor registration-order problem, with nothing pointing at the missingreturn. The documented contract already saidfalse, so this makes the documentation true rather than changing what it promises.MiddlewareFunctionreturnsboolean | voidaccordingly: under the new rule an omitted return is correct, so demanding one would be wrong. A middleware that throws still vetoes, on the grounds that a guard which crashed has not decided the event is safe, but the log now names the event and says the event was vetoed. Code relying on a falsy-but-not-falsereturn to veto changes behaviour; that return was almost certainly not deliberate. - Adds
onSubscriberErrortocreateStore. Reducers, effects, rejections and cascades each had an error hook and event subscribers hadconsole.errorand nothing else, so an application could not route a failing subscriber to its own error reporting. Subscribers are the seam a decoration is told to use, which makes the gap more visible than it was. Covers a throw and a rejected promise alike; a throwing subscriber still never stops the others. - Persistence reports what it could not serialize.
encodeEnvelopediscarded the encode report, so a value the codec could not represent was written as a lossy stand-in and nothing anywhere said so - the loss surfaced later, as a slice that came back wrong. Losses now reachPersistOptions.onErrorunder a new"encode"phase, which is its own phase rather than"write"because a serialization loss and a full disk need different responses, and telling them apart is whatonErroris for. Reporting never blocks the write: partial state beats none, and the caller decides what a loss means.dehydrateacceptsonErrortoo, since a server-render handoff has the same exposure. Adding aPersistencePhasemember is a narrow break for an exhaustiveswitchover it. - Adds
store.onRegistrationChange(observer, options?), a push seam for what is installed on a store.__devtoolsIntrospect()is pull-only, so a devtools panel's subscription list went stale the moment anything was registered at runtime, and a library that needed to react to another library had nothing to wait on. Changes arrive as an array, one batch per public call:replaceReducersupdates a slice by unmounting and remounting it, so a per-change observer would see a spurious unmount of something merely being updated, andhotReplacedelivers one batch spanning all three kinds rather than three views of a topology mid-rebuild. Each change is self-sufficient - kind, op, name, origin, owner, the normalized matcher, and for a reducer a four-valuedstatewhere"retained"distinguishes a slice whose state survived an unmount from one whose did not.{ emitCurrent: true }synthesizes a mounted batch for everything already installed, delivered synchronously before the call returns, because spec-time registrations happen insidecreateStoreand a decorator applied afterwards never saw them arrive; the synthesized changes carry their real origins rather than a synthetic marker. Observers run after the state broadcast, so the view layer learns a fact before a library reacts to it. A registration made by an observer is the legitimate ordering-dependency case and is queued rather than delivered re-entrantly, bounded at 64 rounds and logged rather than thrown on overflow. Replay never produces a change,dispose()fires nothing, a throwing observer does not stop the others, and an observer returning a Promise is reported in development and not awaited. Nothing is allocated when nobody is observing. replace*now replaces what the application authored and leaves registrations made after construction alone. A reducer, middleware or effect installed withregisterReducer,registerMiddlewareorregisterEffectwas never part of the set being replaced, and no caller ofreplaceReducers(myReducers)means "and also delete the slice devtools mounted, along with its state". The old reading made the HMR line core's own docstring recommends delete a library's slice and its state on the first file save, with no error and no warning, and it silently turned the disposersregisterMiddlewarehanded back into no-ops. Provenance is recorded internally on every registration; no public signature takes it and no library declares it, because correctness must not depend on anyone remembering to pass a string.replaceReducers,replaceMiddleware,replaceEffectsandhotReplaceaccept{ scope: "all" }to restore the previous wholesale behaviour exactly, which a test harness resetting a store between cases may genuinely want;hotReplaceforwards it to all three, since it is the documented HMR entry point and one flag should be enough. An application that authors a slice a library already mounted now gets an error naming the slice, thrown before anything is mutated, rather than a silent takeover. In development, areplace*that preserved something says so at debug level.__devtoolsIntrospect()reportsoriginon reducers, effects and middleware, andowneron reducers;owneris a label for a panel and is never read when deciding what to preserve.- Event replay no longer notifies
onEventsubscribers. Replay ran every handler exactly as a live event would, and the replay loop's own comment listed what it skips - middleware, effects, dedup, DevTools logging - with subscribers simply missing from that list. So in any application withdevtools: { allowReplay: true }, scrubbing the timeline re-published to peers, re-wrote to sockets and re-fired analytics for events that were not happening again, and nothing inside a handler could tell the difference: the replay flag was private, no flag was stamped on the event, and the handler signature had nowhere to carry one. A subscriber that legitimately wants replayed events, meaning one deriving view state purely from the event stream and performing no I/O, opts in withonEvent(channel, type, handler, phase, { duringReplay: true }).StoreInstance.isReplayingis added for anything that must branch rather than skip. State fidelity is untouched: reducers produce state, and coarsesubscribelisteners andconnectsubscriptions keep firing so the UI can follow a scrub. Anything relying on the old behaviour opts in explicitly, which is the direction that fails safely. - A store decorated after construction now carries the types of what it gained.
registerSlice,withSlice,withMiddlewareandwithEffectreturn the store re-typed: a slice mounted at runtime has a real state type, and the channels a decoration contributes become emittable. PreviouslyregisterReducertook a plainstringand returned a bare disposer, so nothing downstream knew the slice existed, and a spec targeting a channel the application's event map did not contain needed a cast at every registration site. Decoration is type-level only: the same runtime object comes back, so subscriptions, effects, middleware, the dedup cache, both buses and any in-flightstore.call()are untouched and nothing re-subscribes. Calls chain, and decorators compose by nesting in either order, which falls out ofStoreDecoratorbeing generic over the incoming store rather than from any variance rule.registerReducer,registerMiddlewareandregisterEffectnow return a callable object carrying.storeand.dispose, so every existingconst off = store.registerEffect(spec); off();compiles and runs unchanged.withSlicedeliberately returns no disposer: after one runs the widened type still promises a slice that is gone, and no type system can express "valid until that call", so the footgun is kept off the path most people take andregisterSlicecarries the disposer for the library that owns the slice. Reading a disposed slice throughconnectthrows a named error in development rather than handing backundefinedfrom a type that promised a value. AddsDecoration,DecoratedandStoreDecoratoras the contract a third-partywithX(store, config)conforms to. - Adds
defineSlice,defineMiddlewareanddefineEffect, and the type machinery for typing a store that is decorated after construction. A spec'swhencarries channel and type strings and no payload types, so the event map a decoration contributes cannot be inferred from it, and TypeScript has no partial type-argument inference - naming it would force every call site to hand-write the slice name and state type as well. The builders park it in a value position instead, where inference works, so a registration site needs no type argument and no cast. They are identity functions at runtime and add no property: the brand they carry is a phantom, and a real one would surface inObject.keys, in a devtools snapshot and in anything that serializes a spec. Also addsPrettify,Merge,WidenNames,WidenState,SatisfiesSlices,EMAddOf,StateOfSpec,EmptyEventMap,EventMapCarrierand theStoreDecoration/DecoratableStore/WidenedSlicesurfaces. Nothing implements the registration surface yet, so no behaviour changes; the machinery is proven by type-level tests before anything depends on it.
Fixes
- A
TypedArraysubclass no longer decodes toundefined. The binary tag resolved itskindfromconstructor.name, so Node'sBuffer- aUint8Arraysubclass, and everywhere - was taggedkind: "Buffer", which the decoder's allow-list rejects. It came backundefined, reported nowhere, throughpersistas much as through time travel: a silent total loss of the value, which is the failure this module exists to prevent. The kind is now resolved byinstanceof, so a subclass round-trips as its base and keeps its bytes, and the path is reported inEncodeReport.unsupportedbecause losing the subclass is still a loss.Float16Arrayand any user subclass were affected the same way. encodeStateBoundedstops an oversized buffer before encoding it rather than after. The node charge for a binary value was applied followingbytesToBase64, so each attempt of the shrink loop fully base64-encoded a buffer it was about to discard.- The development-only record of disposed slice names is bounded at 64. A long session that mounts and disposes repeatedly would otherwise accumulate an entry per cycle for the lifetime of the store. The oldest is dropped, since the diagnostic exists for a slice someone has just stopped using.
- FORWARD COMPATIBILITY: state persisted by this version containing a typed array,
DataVieworArrayBuffercarries a{"$yoltra":"binary"}tag that 0.7.x'sdecodeStatedoes not recognise. Its tag switch returnsundefinedfor an unknown tag, so downgrading silently loses that slice rather than failing loudly.PersistOptions.versionis the escape hatch, but nobody will think to bump it for a patch that only made serialization more faithful, so it is called out here instead. Rolling back a deployment that has already written binary state to storage needs a version bump or a storage clear. - Adds a decoration guide, in English and Spanish: what the spec builders are for and why the event map cannot be inferred without them, how a library publishes a
withX(store, config)decorator, why decorators nest rather than pipe, how a decoration declares a dependency on another, whatreplace*now preserves, the React rules (module scope, once, before first render), watching registrations, and the one thing types cannot express about disposal. - Corrects documentation that had drifted from the code. The
onEventphase list omitted'written'in both the interface and the implementation, though the phase has existed and been handled in both for a release, and neither mentioned that replay no longer notifies.ReducerSpec's remark described aneventsproperty that was removed, recommendingwhenover something that no longer exists.replaceReducers's docstring documented onlypreserveStateand still implied it replaced everything. - Effect metadata is released when its effect is unregistered or replaced.
effectMetawas never pruned byreplaceEffectsor by any disposer, so entries accumulated for effects that no longer existed. Pruned per dropped effect rather than wholesale, since clearing the map would strip the metadata of every effect being preserved. - Disposing one effect registration no longer strips the metadata of others sharing the same function.
effectMetais keyed by the effect function, and one function can legitimately back several registrations; the metadata is now released only once nothing is still registered with it. - A registration made from inside an
{ emitCurrent: true }snapshot is queued like any other. The snapshot was delivered outside the notifying flag, so an observer that registered on first sight of the store was re-entered rather than queued, breaking the documented contract on the call most likely to trigger it. - An encode that is both truncated and lossy now reports both. A ternary reported only the truncation and discarded the unsupported paths, which are the actionable half: "too large" says retry with less, a named path says which value to change.
freezeStatepasses binary views through untouched instead of throwing.Object.freezeon aTypedArrayorDataViewthat has elements is aTypeErrorby language rule, because indexed properties on a view cannot be made non-configurable - so the development-mode freeze threw at store construction and it was impossible to keep bytes in slice state at all. There is nothing to deep-freeze in any case: the contents are numbers, not a reachable object graph. Immutability for a view is therefore reference-level, the same treatmentMapandSetalready get.hotReplaceno longer half-applies.replaceReducersrefuses to take over a slice a library mounted at runtime, and that refusal fired after middleware and effects had already been swapped, leaving the new module's middleware running against the old reducers. The collision check now runs as a pre-flight across the whole call, before anything is touched. A partial hot reload is harder to diagnose than a refused one.- The
registerSliceandregisterMiddlewaredisposers are idempotent. A second call re-announced the removal to registration observers and, for a slice, re-broadcast to every listener, for something that had already gone. The middleware disposer also announced the unmount before performing it, so an observer that inspected the store was told a middleware had been removed while it was still installed. - A slice mounted after construction now wakes
connectsubscriptions.registerReducerbroadcast tosubscribelisteners but emitted nothing on the connector bus, souseAtomicProp,useAtomicPropsand the Suspense hooks never learned the slice existed: a component subscribed to a path inside it simply never re-rendered, and nothing reported an error. The mount now emits the new slice's root and leaf paths. Found while building the decoration feature, which would otherwise have shipped a documented-as-working path that does not work. - A slice registered with a pattern matcher (
{ any: true },{ channel },{ channels }) is now reported to registration observers.mountSlicereturns early for a pattern slice, so a notification placed at the end of its body fired only for key-dispatched slices. Found by a test written for the new seam rather than by review. - Disposing middleware registered twice now removes the right registration. The disposer used
indexOfon the function, so registering the same middleware twice and disposing once removed the first registration rather than the one being disposed. Entries are spliced by identity. replaceReducersnow notifies subscribers when it changed the state root.registerReducerhas always broadcast after mounting and this never did, so a React tree went on rendering pre-reload state after an HMR pass until something else happened to wake it. Gated on root identity, so a replace that preserves every slice still costs nothing.- Subscribing the same handler function twice through
onEventnow creates two independent subscriptions. The registries stored the bare handler in a Set, so two subscriptions sharing one function were a single member and disposing either removed both. They now store an entry per subscription, which is also where the replay opt-in lives. - Adds an upgrade guide for 0.7.x to 0.8.0, in English and Spanish, and corrects the migration guide's description of middleware, which still said it returns a boolean. The existing migration guide covers arriving from Redux, Zustand or Jotai and had nothing about moving between Yoltra versions, which this release needs: five behaviour changes and a hazard on rollback are hard to extract from a list of thirty changelog entries. Each section leads with how you would notice.
#@yoltra/devtools-browser-agent
Features
- Event payloads and patch values are size-bounded, like snapshots already were. The hub caps a frame at 8 MiB and
wsanswers an oversized one by closing the connection rather than dropping the message, so a single large emit ended the devtools session. Faithful binary encoding makes this reachable in ordinary use: anArrayBuffernow carries its bytes instead of serializing to{}. NewmaxEventBytesoption, defaulting to 512 KiB, far below the snapshot cap because events are frequent and a snapshot is not. A truncated payload is flagged on the wire so a panel can say so rather than showingundefined. - The agent now pushes
STORE_SUBSCRIPTIONSwhen the store's registrations change, instead of only answeringREQUEST_SUBSCRIPTIONS. Introspection is a pull, so the panel's list went stale the moment anything was registered at runtime - a decoration mounting a slice, a hot reload, anonEventadded by a component - with no way for the panel to know and no reason for it to ask again. Peer range moves to@yoltra/core ^0.8.0.
Fixes
- Ship the LICENSE file in the published package.
filesexcluded it and npm does not force-include a licence the way it does a README, so the tarball carried MIT-licensed code with no licence text. - The agent no longer pushes a whole-store snapshot for the store's own internal registrations.
store.call()mounts and unmounts a reply listener per call, so ordinary request/response traffic produced twoSTORE_SUBSCRIPTIONSframes per call - a lot of hub bandwidth to describe something the panel does not display.
#@yoltra/devtools-cli
Fixes
- Add npm version, downloads, types and licence badges to both READMEs, and set
repository.directoryso relative links in the published README resolve to this package's directory rather than the repository root. - Ship the LICENSE file in the published package.
filesexcluded it and npm does not force-include a licence the way it does a README, so the tarball carried MIT-licensed code with no licence text.
#@yoltra/devtools-protocol
Features
StoreEventgains optionalevent.truncatedandpatchesTruncatedflags, so a panel can show that a payload exceeded the agent's per-event byte cap rather than rendering the truncation marker as an absent value.
Fixes
- Ship the LICENSE file in the published package.
filesexcluded it and npm does not force-include a licence the way it does a README, so the tarball carried MIT-licensed code with no licence text.
#@yoltra/devtools-server
Fixes
- Add npm version, downloads, types and licence badges to both READMEs, and set
repository.directoryso relative links in the published README resolve to this package's directory rather than the repository root. - Ship the LICENSE file in the published package.
filesexcluded it and npm does not force-include a licence the way it does a README, so the tarball carried MIT-licensed code with no licence text.
#@yoltra/devtools-storeview
Fixes
- Add npm version, downloads, types and licence badges to both READMEs, and set
repository.directoryso relative links in the published README resolve to this package's directory rather than the repository root. - Ship the LICENSE file in the published package.
filesexcluded it and npm does not force-include a licence the way it does a README, so the tarball carried MIT-licensed code with no licence text.
#@yoltra/devtools-ui
Fixes
- Ship the LICENSE file in the published package.
filesexcluded it and npm does not force-include a licence the way it does a README, so the tarball carried MIT-licensed code with no licence text.
#@yoltra/react
Features
- Peer range moves to
@yoltra/core ^0.8.0, which it should always have been for this release. The shipped source callsstore.registerSlice, passesonEventa fifth argument and imports six types that exist only in 0.8.0, so the old^0.7.0range let a consumer install a combination that satisfied npm and then failed at runtime withstore.registerSlice is not a function, while the replay opt-in was silently ignored by a core that only accepted four arguments. useAtomicPropsnow refuses an undeclared read on every path, not just one. The hook had two implementations, and the declared-path guard lived only in the package-level copy - the one the barrel deliberately steers people away from. ThroughcreateYoltra, which the documentation recommends, a selector could read state it never declared: it received the right value once and then never re-rendered, because the component is subscribed to the declared paths only. Silently, in the release whose theme is removing silent failures. The two implementations are collapsed into one, so the package-level hooks are now typed faces over the same setcreateHooksreturns and a fix can no longer land in one copy and miss the other. Behaviour change: code that read an undeclared path throughcreateYoltranow throws in development, naming the path and the fix, where it previously returned the value and quietly stopped updating. TheuseStoreerror message unifies onto the more actionable of the two.createYoltranow returns a chainable hook set:withSlice,withMiddlewareandwithEffectare methods on the returnedYoltra, and free-function forms are exported for a library handed aYoltrait did not create. Each returns a new hook set bound to the same context object, re-typed, so a<StoreProvider>from any view in a chain serves the hooks of every other and the Suspense cache is shared, since it keys on store identity. Call them at module scope, once, before the first render:createHooksallocates fresh function objects per call. Also strengthens the public-export test, which was a one-waytoHavePropertyloop and had drifted -createYoltraand the entity hooks were exported and missing from the asserted list.useEventforwards the replay opt-in:useEvent(channel, type, handler, phase, { duringReplay: true }). Without it a component had no way to ask for replayed events, sinceonEventgrew the option and the hook did not pass it. The effect's dependency array takesoptions?.duringReplayrather thanoptions, because an object literal is a new reference on every render and depending on it would unsubscribe and resubscribe on each one - a loop that leaves the subscription looking correct at every point you might inspect it, so it has its own regression test.
Fixes
- Fixes
useAtomicPropsthrowing when a glob covers both a container and the paths beneath it.satellites.**expands tosatellitesand the paths under it, so the projection assigned the store's real array at the container path and then tried to write an index into it. State is frozen in development, so that threwCannot assign to read only property '0'; in production it would have silently mutated the store's own state, which is worse. A container borrowed from state is now shallow-copied before anything is written into it. Latent until the declared-path guard moved intocreateHooks, which is what put this path in front of the hookscreateYoltrahands out. - Documents the 0.8.0 React surface in both READMEs, which had neither: the replay opt-in on
useEvent, thewrittenphase, and the chainablewithSlice/withMiddleware/withEffectwith the module-scope rule and the shared context and Suspense cache. Also corrects a quoted error message that the hook collapse had already changed.