Yoltra 0.4 release notes
#0.4.0 (2026-08-07)
#@yoltra/core
Features
- A cluster of small honesty fixes. __applyExternalState now throws when devtools replay is disabled instead of warning and returning, matching __replayEvents: both replace the state tree wholesale for a devtools client, and refusing quietly in one case made a disabled seam look like a working one that had found nothing to do. BREAKING for anyone calling that internal seam without devtools.allowReplay. A slice whose initial state cannot be structured-cloned now names the slice and the likely cause rather than surfacing a bare DataCloneError that identifies nothing. replaceMiddleware accepts the spec form, so a hot reload no longer silently discards the when targeting of a middleware scoped to one channel and starts running it on all of them. The keyed-reducer id fallback uses the injected idFactory rather than calling crypto.randomUUID directly, which is the whole reason that seam exists for React Native and for deterministic tests. The per-event changed-path array is no longer allocated when no instrumentation observer is attached, the one un-gated line in an otherwise fully gated block.
- Adds
createEntityAdapter, for collections that reorder. Path notification is positional for arrays,items.0.titlenames a slot, not a thing, sounshift,splice(0, 1)andsortmove nearly every element into a different slot, and the diff correctly reports that nearly every leaf changed. Inserting one row at the front of a thousand woke a thousand subscribers. That is honest rather than noisy, and the remedy is the shape of the state rather than a diff that stays quiet: a key-stable array diff would need an identity key the diff has no business knowing, and its paths would still be positional, as is the RFC-6902 pointer built from them.\n\nNormalising to{ ids, entities }makesentities.abc.titlestable across insert, remove and reorder. Untouched entities keep their references, so the diff skips them in constant time. The adapter also hands out the subscription paths, because a path typed into a component is a string nothing checks.\n\nidsis still an array, so a reorder still reportsids.0,ids.1and so on: the cost is confined, not removed. A list container subscribes toids; rows subscribe to their own entity and sleep through a sort. - Change notifications are now built only when somebody is listening. Describing a change carries the old and new value at a path, and reading those walks the state tree twice per path, work a slice with no subscribers used to do for every path it changed. The bus gained emitWith, which performs the same matching and constructs the payload once, only after a handler has matched. Registering or unregistering a slice now produces a new root state reference instead of writing into the existing object, so consumers that bail out on reference equality, useSelector comparing with Object.is, a memo, a devtools snapshot differ, can see that the shape of the state changed; structural sharing is preserved, so untouched slices keep their identity. A middleware that returns a Promise is reported in development: a Promise is truthy, so the event is allowed without waiting and a veto inside it can never fire, and the symptom, a rule that simply does not apply, looks nothing like the cause. The middleware documentation no longer demonstrates the async form it cannot support.
- Adds persistence and hydration. The two halves are separate functions because they happen on opposite sides of the store's existence:
hydrateproduces initial slice state so the store is born with it, andpersistsubscribes to one that already exists. Restoring after construction, applying a snapshot to a live store, emits a change across every path, which on boot is a flash, a burst of instrumentation entries describing changes nobody made, and effects observing a transition that never happened.\n\nNothing throws on boot: a missing, unparseable or unmigratable payload falls back to the declared defaults and reports throughonError, because a store that will not start because storage holds stale JSON is worse than one that starts fresh. Version mismatches are refused rather than trusted, a snapshot written against older reducers may not be valid state for this build at all. Writes are driven by instrumentation, so a change confined to an unpersisted slice costs nothing, and a burst coalesces into one write.\n\nThe state codec moves here from@yoltra/devtools-protocol, which is a zero-dependency leaf and must stay one. It serializes store state, which is a core concept, and persistence needs the same one:JSON.stringifydoes not fail on a Map, it silently turns it into{}, and a serialization format with two implementations is one that will eventually disagree with itself about a user's saved data. - A reducer that throws now behaves the same way whichever way its slice is targeted, and is observable. Keyed reducers ran through a bus whose handler loop caught and logged, so the event committed and its effects ran as though nothing had happened; pattern reducers were called straight from the drain, so the throw escaped, aborted the commit, and notified nobody, not even the uncommitted subscribers a middleware veto reaches. Both paths now funnel through one guarded call that isolates the failing slice: its state is unchanged, every other slice still reduces, and the event still commits if anything else changed. Isolation rather than rollback, because fine-grained subscribers are notified as each slice commits and an event that reverted afterwards would already have announced a value that no longer exists. Adds an onReducerError hook, mirroring onEffectError, receiving the error, the event and the slice name, emit() never rejects for a reducer error, so this is how a caller observes one. Event replay is guarded the same way, so one bad event in a replayed log costs that event rather than abandoning the replay midway. Separately, the development-only deep freeze now warns, once per slice and event, when a reducer stores the event payload by reference: the caller still holds that object, mutating it later throws from a stack that never mentions the store, and the same code works in production because the freeze is compiled out.
- BREAKING (pre-1.0): remove the legacy events targeting field from ReducerSpec and EffectSpec. The when matcher has been the documented form since it landed, events survived only as a deprecated alias, and carrying both meant every doc, example and answer said 'or the old form' indefinitely. Migration is mechanical, events: [...] becomes when: { keys: [...] }, and the whole monorepo (48 files, 109 sites) moved in this change. The middleware dual form (bare function or spec) stays deliberately: the spec adds targeting and metadata around the same function and both normalize into one pipeline at registration, which is a shorthand, not a parallel path.
- Two typing defects that no test could have caught, because the tests were never typechecked.
DeepReadonlymapped over the method names ofMap,Set,Date,RegExpand any function held in state, so reading a Map out of state and calling.get()on it was a type error against a value that is an ordinary Map at runtime; collections now become theirReadonlycounterparts, as arrays already did, and functions are left callable. AndcreateStore,registerMiddlewareandhotReplaceall typed middleware as bare functions while the field behind them, the pipeline that reads it, and the documentation took either form, so the spec form the docs call recommended did not compile. Both are pinned by type-level tests.
Fixes
- Remove the unreachable pattern-matcher branches from the store's event-key normalization: both call sites intercept any/channel/channels targeting before normalizing, so only keys could ever arrive. Behaviour is unchanged; the dead branches were the reason the path could not be covered honestly. Coverage floors for branches and functions now hold 95 percent alongside lines and statements, raised after the uncovered paths gained real tests, the storage adapters, the bounded encoder's rescale and give-up paths, the batch entity variants and the sorted-ids identity contract, persistence write failures and scoped slices, the bus's throwing-handler containment, and dev-mode freezing's accessor edges.
- Cut the allocation out of
detectChangedProps, which sits on the hot path of every commit. Diffing a thousand-entity normalised map after a one-field update goes from 467 to 88 microseconds; the equivalent array update goes from 20 to 2.2; a 200-key object from 68 to 11. End-to-endemitis 2.5 to 2.8 times faster as a result.\n\nThree things were being allocated for work nobody read.\n\nThe key union was built unconditionally, two key arrays and aSetper object comparison, at every level of the tree. It is only needed when the two sides carry different keys, and an update changes values rather than shape. Equal key counts plus one-way containment establishes that cheaply, and when the shapes really do differ, two passes over the key lists find additions and removals without materialising a union at all.\n\nEvery node returned a freshstring[]that its parent spread back in, roughly four thousand short-lived arrays per diff of a thousand entities, for a result that is usually one path. The recursion now writes into a single accumulator.\n\nAnd every child had its dotted path concatenated before anything checked whether the child had changed. The identity comparison now happens first, so an update touching one key of many no longer builds a string per key. This is the largest of the three.\n\nOne behavioural note: the development-mode warning about a state key containing a dot now fires when such a key is part of a change rather than whenever its object is diffed. The ambiguity it warns about only exists for a path that is actually reported, so it still fires before it can mislead anyone. - Warns in development when a state key contains a dot. Paths are dotted, so
{ "a.b": 1 }and{ a: { b: 1 } }both produce the path "a.b": a subscription to it may match the wrong value, and a DevTools patch for it addresses the wrong node. Nothing downstream can recover the difference from the joined string, which is why it is reported at the diff, where the key is still intact, once per key rather than once per event. - Two silent failures in change detection. A change to an array's length reported the array path and stopped there, so an exact subscription on a path inside it was never notified: after an unshift or a splice, a component bound to items.0.title, the example the documentation leads with, kept rendering the previous occupant of that index, with no selector or memo involved and no way to notice. The diff now reports the array path, the leaves that genuinely moved among the overlapping indices, and the indices that appeared or vanished. With positional paths a shift really does change the value at almost every index, so this is honest rather than noisy; the remedy for that cost is identity-keyed state, not a diff that stays quiet. Separately, Map and Set keep their contents outside own enumerable keys, so the key-walk compared two empty objects, reported no change, and the store, which treats no changed paths as a no-op, skipped the commit entirely: a reducer returning a new Map produced no state update, no subscriber notification and no error. Such values are now reported at their own path, making reactivity for them reference-level rather than absent, and two distinct references with nothing enumerable to compare (a class instance holding state in private fields, for example) are assumed changed rather than equal.
- Index wildcard subscriptions by their first segment, so an emit tests only the patterns that could match rather than every pattern on the channel.\n\nDelivery used to walk the whole pattern map and run the segment matcher against each entry, linear in the number of patterns registered rather than in the number that match, and the matcher re-split both the pattern and the subject on every test. A thousand patterns meant two thousand string splits to deliver one event.\n\nA subject's first segment can only be matched by a pattern whose first segment is that same literal, or is
*or**. Bucketing on that turns the common shape, distinct event families likepanel.*besideorder.**, from a scan into a map lookup. Patterns are split once at registration; the subject once per emit.\n\nWith a thousand patterns registered, wildcard delivery goes from 4,290 to 2,529,253 operations per second, and stops growing with the number of patterns: 1 or 1000 now cost the same.\n\nThe index cannot narrow a channel whose patterns all begin with**, since nothing about the subject rules any of them out. That case stays linear and is roughly ten times faster than before from the split hoisting alone. A benchmark row covers it, so the headline figures are not read as an unqualified win.\n\nemitandemitWithnow share one candidate walk instead of carrying two copies of it, and the private string-taking matcher is gone with its last caller.
#@yoltra/devtools-browser-agent
Features
- Accepts an authToken, forwarded to the hub on every handshake, so a hub started with a token can be used on a shared or containerised host where any local process could otherwise connect as a panel.
- State snapshots are bounded before they are sent, with a maxSnapshotBytes option defaulting to 6 MiB, under the hub's 8 MiB frame cap, since the snapshot travels inside an envelope and a frame that overshoots by a few hundred bytes is refused exactly like one that overshoots by a megabyte. An oversized frame was not a loud failure: the hub refused it and dropped the connection, so the client reconnected, asked again, was refused again, and the panel waited through the loop with nothing on screen to explain it. A shortened snapshot now says so rather than presenting a partial tree as the state.
- Adds a postMessage transport so a page can be inspected without a hub process. Attaching a browser panel required running a server, editing the application, and setting a capability flag, three steps against Redux DevTools' one, at the moment somebody is deciding whether the tool is worth the trouble. The agent now picks its transport: when an extension's content script has announced that it is relaying the page, protocol frames travel over postMessage; otherwise it opens a WebSocket to the hub as before, which is still the only way into a Node process or a remote session. A transport option forces either, and an explicitly injected socketFactory always wins, so the embedded loopback panel and the tests are unaffected. The socket reports itself open immediately because postMessage has no connection to establish; when nothing is relaying, the handshake simply goes unanswered and frames buffer exactly as they do against a hub that is not running, which keeps one failure mode to explain rather than two.
- Expose the state codec's sanitize hook through withDevtools. State and event payloads frequently hold tokens, session material and personal data, and everything the agent forwards crosses a socket to another process, until now, unredacted. The hook is applied to every value the agent encodes: state snapshots, time-travel snapshots, event payloads and state patches. Patch values get the op's own path prefixed onto the codec's, because a leaf patch encodes a bare scalar whose internal path is empty, without the prefix, a recipe keyed on key names would redact the snapshot and the payload while the same secret slipped through in the patch. Redaction happens at the wire; the live store keeps its real values.
Fixes
- State, event payloads and patch values now cross the wire through the protocol codec instead of JSON.stringify. Two failures went with it. A Map in application state reached the panel as {} and time-travel sent that {} back and applied it to the running store, so a debugging tool silently emptied a live collection inside the program it was inspecting. And a BigInt or a cycle threw from inside a message handler nothing awaits, so no snapshot ever arrived and the panel retried every 1.5 seconds forever. Inbound time-travel state is decoded before it reaches the store, closing the round trip.
- A command handler that throws is now caught and reported instead of becoming an unhandled rejection. The message callback is async and nothing awaits it, so a failure inside it surfaced far from its cause or nowhere at all, while the panel sat waiting for a reply that never came.
- Imports the state codec from
@yoltra/core, where it now lives, rather than from the protocol package.
#@yoltra/devtools-cli
Features
- The terminal gains a Time Travel tab. Both surfaces consume the same hook, so the same session could be scrubbed in a browser and not in a shell for no reason other than the tab never being added. A terminal has no slider, so arrow keys step through the history and the bindings are shown on screen; a store that did not advertise replay gets an explanation rather than an empty panel, since the capability is off by default and the reason is not obvious. Components are rendered in tests now.
Fixes
- The package runs tests now, its script was
exit 0and vitest was not even installed. Argument parsing moves out of the entry point, where it could only be reached by starting a hub and rendering a terminal app, and is validated: a port outside the bindable range was accepted here and rejected deep inside the socket library, so the user saw a stack trace from a dependency instead of being told which flag was wrong, and a non-numeric value fell back to the default so the tool listened somewhere nobody had asked for. Both are refused with a message naming the flag, exiting 2 rather than crashing.
#@yoltra/devtools-protocol
Features
- HandshakeRequest carries an optional authToken, and the reconnecting client presents it on every handshake including after a reconnect. Required only by a hub that was started with one.
- Adds encodeStateBounded, which shrinks an encoded value until its serialized form fits a byte budget, and truncated/truncationNote fields on StateSnapshot so a shortened tree can say that it is one. Node count is a poor proxy for bytes, a hundred nodes holding blobs outweigh a hundred thousand holding integers, so the output is measured and the node budget rescaled by how far it overshot. Scaling by the overshoot rather than halving is what makes it converge: from the default hundred thousand nodes, repeated halving needs a dozen rounds to reach the hundreds, and a state that could have been shown in part would have been abandoned instead.
- Adds encodeState and decodeState, a lossless encoding of store state for a JSON wire. JSON.stringify does not fail on the values it cannot represent, it destroys them: a Map or Set becomes {}, a Date becomes a string, undefined disappears from objects, and a BigInt or a cycle throws. Values are now tagged rather than coerced, so Map, Set, Date, RegExp, Error, BigInt, undefined, NaN, the infinities, cycles and shared references all survive a round trip exactly. An application object that happens to carry the marker key is escaped so it decodes unchanged. Functions and symbols have no faithful representation and decode to undefined rather than a placeholder pretending otherwise, with their paths listed in the encode report. Encoding also accepts a sanitize hook, so tokens and personal data can be redacted before state crosses a socket, and a node budget that truncates visibly instead of producing a frame the hub will reject, which reads to a user as a panel that hangs.
- The state codec moves to
@yoltra/core. It serializes store state, and persistence there needs the same implementation; re-exporting it from here would mean this package depending on core, and it is deliberately a leaf with no dependencies at all. The browser and node agents already peer-depend on core and now import it from there.\n\nCoverage thresholds are re-based as a result. Nothing went untested: a well-covered 400-line module left the package and took its tests with it, leavingpatch-utilsat 96% besidews-transportat 28%. The old numbers were the codec carrying the average, and the gap inws-transportis now visible rather than averaged away.
#@yoltra/devtools-server
Features
- Adds an optional authToken the hub requires from every client's handshake, compared in constant time and checked before the client is registered or replayed any history. Binding to loopback keeps the network out, but loopback is not an authentication boundary: every other process on the machine can reach the hub, so without a token anything running locally could connect as a panel and read the application's entire state, inject events, and overwrite state through time-travel, a package install script, or another tenant on a shared CI runner. The token stays unset by default, because requiring one would break the zero-configuration local flow the tool exists for; instead the hub now says once at startup that it is running open and what that means, so the exposure is stated rather than presented as a secure default.
- Adds allowedExtensionIds, narrowing which browser extensions may open a socket. Extension origins all share one scheme, so permitting the scheme permitted every extension the user has installed, any of which, from a devtools page of its own, could connect and read whatever the attached stores hold. Naming ids restricts that to the panel actually meant to connect. Left empty by default because there is no id to assume: an unpacked build and a store install have different ones, so a hardcoded default would lock out a developer running the extension they had just built. Meant to be set alongside authToken, since an origin check constrains which page opened the socket and says nothing about which process did.
- History replayed to a newly-connected panel now skips events from stores that have since disconnected: a long-lived hub greeted every new panel with a burst of frames for stores it cannot select, sent one at a time, ahead of anything useful. Metrics are fanned out only to extensions that declared they display them, the capability flags were advertised and then ignored, so every extension received every message whether or not it had anywhere to put it. The remaining flags describe what an extension can render rather than what traffic it wants, and are documented as such instead of implying a filter that never existed.
- Adds a per-connection message allowance, defaulting to 200 per second. A command like REQUEST_STATE costs the store a full serialization and the hub a fan-out, so a client looping on it turned one cheap socket write into repeated work across every connected process. The excess is dropped rather than the socket closed: severing would punish a brief burst exactly like a flood, and a panel that overshoots recovers on the next window.
#@yoltra/devtools-storeview
Features
- The time-travel panel offers event replay, which the UI package had implemented and exported and no panel ever called. Replay differs from scrubbing: time-travel sets the state at a point, replay re-runs the recorded events through the reducers alone, no effects, no middleware, which is how a reducer is checked against the transitions actually recorded. The action appears only when the store advertises the capability. The package also renders in its tests now rather than being asserted by reading it.
Fixes
- Tab availability moves into a tested module. The rule is small but it decides what a user may click, and inside the app component it could only be reached by rendering the whole panel against a mock hub. Both capabilities it consults default to off, so most stores support only part of the panel, and offering a tab whose controls silently do nothing is worse than not offering it. Switching stores now resolves through the same rule, so a panel left on Time Travel and pointed at a store that cannot replay falls back rather than sitting on dead controls.
TimeTravelPanelPropsis exported. The panel's props were declared inline, so no caller, including its own test, could name the type it renders from.
#@yoltra/devtools-ui
Features
- Panel messages carry the identity the panel already had. Every command went out with an empty sourceId, so the hub could not tell one panel's commands from another's, with two open, or an authenticated hub auditing who drove a store, a time-travel or an injected event was attributable to nobody. The id generated for the handshake is now exposed on the hub context and stamped on everything sent.
Fixes
- The in-memory hub names the negotiated protocol version by the field the interface and the real hub use. It sent
protocolVersionwhere both declarenegotiatedVersion, harmless only while nothing read it, and the moment something did, the embedded panel would have failed where the real hub worked, in the one path with no server to inspect. - Two costs that made the panel unusable on a large store are gone. applyPatches deep-cloned the whole state before applying anything, although setAtPath and removeAtPath already copy each node along the touched path and never write into their input, so the clone duplicated the entire tree only to have the parts that mattered copied again, turning an operation proportional to the change into one proportional to the store, on every event. It also destroyed structural sharing, so a one-field update looked to the panel's memoization like the whole store had changed. Measured on a 3000-row state, applying one patch went from 4.9ms to 0.75ms. Separately, state reconstruction replayed from the baseline through every entry on every call, and live events arriving while the panel is open triggered one such replay each, quadratic in the length of the history. It now continues from the previous result, validated by the identity of the entry it was built against rather than by position, because the event log is capped and drops its oldest entries, which shifts every index and would otherwise let a cache resume from the wrong entry and produce a state that never existed. Measured over 300 events on a 500-row state, 2834ms became 19.7ms. The replay is extracted as a pure replayState function, following the same reasoning that put the step-boundary maths in planStep: it is the part with the interesting failure modes, and inside a hook it can only be reached through a rendered component.
#@yoltra/react
Features
useAtomicPropsnow hands its selector only the paths declared alongside it, rather than the whole store.\n\nThe two arguments used to be independent: a list of paths that wake the component, and a selector given all of state. Nothing tied them together, so subscribing to one path and reading another compiled, ran, and worked for exactly as long as the two happened to change together. This repository shipped that bug in its own example, a list subscribed totodo.filterwhile readingtodo.data, re-rendering only because adding a todo also rewrotefilter.categories. An edit that left the categories alone would have rendered stale rows and reported nothing.\n\nThe fix removes the opportunity rather than detecting the mistake. Reading undeclared state is nowundefinedon the first render instead of a correct value that goes stale later, and in development it throws with the path named and the existing declarations listed. Wildcards are expanded against real state rather than approximated by their static prefix, since handing over everything underitems.*would reopen the same hole one level down. Containers are rebuilt preserving arrays as arrays, and leaves are copied by reference so identity-based memoization downstream is unaffected.\n\nBreaking only for code that was already wrong: every existing test passed unchanged.- Adds
useEntityIds,useEntityanduseEntityField, pairing withcreateEntityAdapter. Thin wrappers overuseAtomicPropwhose value is that the subscription path comes from the adapter rather than being written by hand in a component, where nothing verifies it and an id-format change breaks it silently. - BREAKING (pre-1.0): the standalone useAtomicProp, useAtomicProps, useSuspenseAtomicProp and useSuspenseAtomicProps exports are gone from the package barrel. With no store context to infer from, every standalone call site needed explicit type parameters, four of them, and the identical hooks arrive fully inferred from createHooks or createYoltra, which the docs have recommended all along. The pruning also converts a runtime footgun into a compile error: a barrel copy read the package-level context, so mixing it with bound hooks threw inside a provider-less tree with nothing in the types to warn you. The context-generic simple hooks (useSelector, useEmit, useEvent, useStore) and the suspense cache utilities remain.
createYoltraandcreateHooksnow returnuseSuspenseAtomicPropanduseSuspenseAtomicPropsbound to their own context, so the hook set they hand back is complete.\n\nThose two were only ever exported from the package barrel, where they read the package-levelStoreContext. Neither factory fills that context, both build a private one, so reaching for Suspense alongside them threwuseStore must be used inside <StoreProvider>the moment the component rendered. Nothing in the types said so, because the barrel's copies and the bound ones are identical in shape, and the workaround (mount a<StoreProvider>carrying the store the factory already returned) reads as ceremony rather than as the fix for a real split.\n\nSuspense cache entries are now scoped per store as well. Keys werereducer::path, which two stores sharing a reducer name collide on: whichever loaded first served its value to the other. That was already reachable by scoping a store with<StoreProvider>, and binding the hooks per context makes it ordinary.invalidateAtomicPropandinvalidateAtomicPropsByReducername a path and no store, so they clear that path in every store that cached it; the by-reducer form also now reaches multi-path entries where the reducer is not the first component of the key.\n\nThe barrel's copies are unchanged and still read the package-level context, the right choice when you are providing the store with<StoreProvider>anyway.- A failed Suspense load is now delivered to the nearest boundary once and then forgotten, so resetting that boundary retries.\n\nCached errors used to be held until something invalidated them, which meant a retry button could not retry: the boundary reset, the component re-rendered, and the stored error was thrown again without the loader ever running a second time. A transient network failure became permanent for the life of the page.\n\nDelivery is tracked explicitly rather than by clock. Expiring the entry on time alone drops it during the gap between the load rejecting and React re-rendering, so the component suspends again instead of surfacing the error, a silent retry loop in place of a visible failure.\n\n
errorTtlMsputs a floor between attempts for a loader that fails fast, andnullrestores the old hold-until-invalidated behaviour as something a caller asks for rather than the default nobody chose.
Fixes
- Drops react-dom from peerDependencies. No source file imports it, the hooks build on useSyncExternalStore, which lives in react itself, while the exports map advertises a react-native condition that react-dom cannot satisfy. Requiring it turned away React Native and react-test-renderer consumers for a dependency the package never used.
- The package is now type-checked and built with strict mode. Its tsconfig never set it and the build config extends that file, so the published declarations for a library whose selling point is end-to-end type safety were emitted checked more loosely than the code consuming them. The source turned out to be strict-clean already, nothing had been verifying it. Coverage thresholds were also written as fractions, which Vitest reads as percentages: the gate believed to require 95% was enforcing 0.95%. They are now real per-metric percentages set at the level actually met, so a regression fails the build.
- The Suspense cache is bounded. Keys are built from the reducer and the subscribed path, so a component reading a dynamic path mints one per value it has ever seen, unbounded, that grew for the lifetime of the process, and because the cache is module-scoped it grew across server requests too. Entries are now capped with least-recently-used eviction, keyed on read order rather than load order, and a load still in flight is never evicted: a suspended component is waiting on that promise and dropping it would leave it suspended with nothing to resume it.