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, and replaceEffects cleared 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".
  • encodeState no longer destroys binary values and exotic prototypes in silence. The walker fell through to Object.entries with no prototype guard, so a Uint8Array encoded as {"0":1,"1":2}, an ArrayBuffer as {}, and a class instance as a plain object with its prototype gone - none of it reported in EncodeReport.unsupported. Both consumers were affected: persistence encodes on write, and time-travel applies decoded state to the live store. All nine typed arrays plus DataView and ArrayBuffer now round-trip through a binary tag 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: kind arrives off a socket, and a frozen literal still answers "constructor" from Object.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 becoming undefined - 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. A Map, Set, ArrayBuffer or 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. A BigInt or 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: null and undefined both produced ::null, and String(payload) made the number 1 and the string "1" the same event, as it did true and "true". Plain-object keys are sorted, so key order is not content; array and Map order 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 when dedupWindowMs is greater than zero, which is off by default, and an explicit dedupKey bypasses 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.
  • emit now says why an event did not commit. committed: false arrived 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.reason is "vetoed", "deduped" or "cascade", and EmitResult.vetoedBy names the middleware that refused, from its meta.name or its own function name. That closes an asymmetry: a reducer refusal has always named its slice through rejectedBy and onRejected, 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, so undefined, 0, "", null and NaN all 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, when or registration-order problem, with nothing pointing at the missing return. The documented contract already said false, so this makes the documentation true rather than changing what it promises. MiddlewareFunction returns boolean | void accordingly: 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-false return to veto changes behaviour; that return was almost certainly not deliberate.
  • Adds onSubscriberError to createStore. Reducers, effects, rejections and cascades each had an error hook and event subscribers had console.error and 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. encodeEnvelope discarded 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 reach PersistOptions.onError under 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 what onError is for. Reporting never blocks the write: partial state beats none, and the caller decides what a loss means. dehydrate accepts onError too, since a server-render handoff has the same exposure. Adding a PersistencePhase member is a narrow break for an exhaustive switch over 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: replaceReducers updates a slice by unmounting and remounting it, so a per-change observer would see a spurious unmount of something merely being updated, and hotReplace delivers 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-valued state where "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 inside createStore and 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 with registerReducer, registerMiddleware or registerEffect was never part of the set being replaced, and no caller of replaceReducers(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 disposers registerMiddleware handed 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, replaceEffects and hotReplace accept { scope: "all" } to restore the previous wholesale behaviour exactly, which a test harness resetting a store between cases may genuinely want; hotReplace forwards 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, a replace* that preserved something says so at debug level. __devtoolsIntrospect() reports origin on reducers, effects and middleware, and owner on reducers; owner is a label for a panel and is never read when deciding what to preserve.
  • Event replay no longer notifies onEvent subscribers. 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 with devtools: { 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 with onEvent(channel, type, handler, phase, { duringReplay: true }). StoreInstance.isReplaying is added for anything that must branch rather than skip. State fidelity is untouched: reducers produce state, and coarse subscribe listeners and connect subscriptions 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, withMiddleware and withEffect return the store re-typed: a slice mounted at runtime has a real state type, and the channels a decoration contributes become emittable. Previously registerReducer took a plain string and 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-flight store.call() are untouched and nothing re-subscribes. Calls chain, and decorators compose by nesting in either order, which falls out of StoreDecorator being generic over the incoming store rather than from any variance rule. registerReducer, registerMiddleware and registerEffect now return a callable object carrying .store and .dispose, so every existing const off = store.registerEffect(spec); off(); compiles and runs unchanged. withSlice deliberately 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 and registerSlice carries the disposer for the library that owns the slice. Reading a disposed slice through connect throws a named error in development rather than handing back undefined from a type that promised a value. Adds Decoration, Decorated and StoreDecorator as the contract a third-party withX(store, config) conforms to.
  • Adds defineSlice, defineMiddleware and defineEffect, and the type machinery for typing a store that is decorated after construction. A spec's when carries 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 in Object.keys, in a devtools snapshot and in anything that serializes a spec. Also adds Prettify, Merge, WidenNames, WidenState, SatisfiesSlices, EMAddOf, StateOfSpec, EmptyEventMap, EventMapCarrier and the StoreDecoration / DecoratableStore / WidenedSlice surfaces. 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 TypedArray subclass no longer decodes to undefined. The binary tag resolved its kind from constructor.name, so Node's Buffer - a Uint8Array subclass, and everywhere - was tagged kind: "Buffer", which the decoder's allow-list rejects. It came back undefined, reported nowhere, through persist as 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 by instanceof, so a subclass round-trips as its base and keeps its bytes, and the path is reported in EncodeReport.unsupported because losing the subclass is still a loss. Float16Array and any user subclass were affected the same way.
  • encodeStateBounded stops an oversized buffer before encoding it rather than after. The node charge for a binary value was applied following bytesToBase64, 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, DataView or ArrayBuffer carries a {"$yoltra":"binary"} tag that 0.7.x's decodeState does not recognise. Its tag switch returns undefined for an unknown tag, so downgrading silently loses that slice rather than failing loudly. PersistOptions.version is 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, what replace* 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 onEvent phase 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 an events property that was removed, recommending when over something that no longer exists. replaceReducers's docstring documented only preserveState and still implied it replaced everything.
  • Effect metadata is released when its effect is unregistered or replaced. effectMeta was never pruned by replaceEffects or 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. effectMeta is 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.
  • freezeState passes binary views through untouched instead of throwing. Object.freeze on a TypedArray or DataView that has elements is a TypeError by 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 treatment Map and Set already get.
  • hotReplace no longer half-applies. replaceReducers refuses 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 registerSlice and registerMiddleware disposers 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 connect subscriptions. registerReducer broadcast to subscribe listeners but emitted nothing on the connector bus, so useAtomicProp, useAtomicProps and 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. mountSlice returns 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 indexOf on 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.
  • replaceReducers now notifies subscribers when it changed the state root. registerReducer has 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 onEvent now 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 ws answers 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: an ArrayBuffer now carries its bytes instead of serializing to {}. New maxEventBytes option, 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 showing undefined.
  • The agent now pushes STORE_SUBSCRIPTIONS when the store's registrations change, instead of only answering REQUEST_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, an onEvent added 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. files excluded 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 two STORE_SUBSCRIPTIONS frames 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.directory so 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. files excluded 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

  • StoreEvent gains optional event.truncated and patchesTruncated flags, 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. files excluded 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.directory so 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. files excluded 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.directory so 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. files excluded 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. files excluded 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 calls store.registerSlice, passes onEvent a fifth argument and imports six types that exist only in 0.8.0, so the old ^0.7.0 range let a consumer install a combination that satisfied npm and then failed at runtime with store.registerSlice is not a function, while the replay opt-in was silently ignored by a core that only accepted four arguments.
  • useAtomicProps now 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. Through createYoltra, 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 set createHooks returns and a fix can no longer land in one copy and miss the other. Behaviour change: code that read an undeclared path through createYoltra now throws in development, naming the path and the fix, where it previously returned the value and quietly stopped updating. The useStore error message unifies onto the more actionable of the two.
  • createYoltra now returns a chainable hook set: withSlice, withMiddleware and withEffect are methods on the returned Yoltra, and free-function forms are exported for a library handed a Yoltra it 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: createHooks allocates fresh function objects per call. Also strengthens the public-export test, which was a one-way toHaveProperty loop and had drifted - createYoltra and the entity hooks were exported and missing from the asserted list.
  • useEvent forwards the replay opt-in: useEvent(channel, type, handler, phase, { duringReplay: true }). Without it a component had no way to ask for replayed events, since onEvent grew the option and the hook did not pass it. The effect's dependency array takes options?.duringReplay rather than options, 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 useAtomicProps throwing when a glob covers both a container and the paths beneath it. satellites.** expands to satellites and 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 threw Cannot 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 into createHooks, which is what put this path in front of the hooks createYoltra hands out.
  • Documents the 0.8.0 React surface in both READMEs, which had neither: the replay opt-in on useEvent, the written phase, and the chainable withSlice / withMiddleware / withEffect with the module-scope rule and the shared context and Suspense cache. Also corrects a quoted error message that the hook collapse had already changed.
Report a problem with this page