What's new in Yoltra 0.10
Yoltra 0.10 is about the edges of a store's life: how its work ends, how it keeps time, and what it reports while it runs. A disposed store now stops, time and timers come through ports you can replace, and every failure and warning flows through one diagnostics seam. Most applications upgrade without code changes; the one change that can surface at startup is a matcher that could never match, which now throws.
#Store lifetime and cancellation
dispose() used to clear the registries and leave the rest running: a late emit still ran
middleware, and a pending call waited out its idle timeout. A disposed store is now inert.
emit() resolves { committed: false } without running anything, call() rejects with
CallAbortedError("store disposed"), as does every call still waiting for a reply, and the
registration methods throw, naming the store. dispose() can be called twice safely.
Your own work can now follow the same lifetime. store.signal is
an AbortSignal aborted last in dispose(). Effects receive a fourth argument whose ctx.signal
aborts when the effect stops being registered (its disposer runs, a hot reload replaces it, or the
store is disposed); effects written with three parameters are unaffected. store.whenIdle()
resolves when no event waits to be reduced and no effect runs, so a teardown can wait for the work
in flight instead of guessing.
store.signal.addEventListener("abort", () => socket.close());
store.registerEffect({
when: { keys: [["search", "query"]] },
effect: async (event, getState, emit, ctx) => {
const res = await fetch(`/api/search?q=${event.payload}`, { signal: ctx.signal });
if (ctx.signal.aborted) return;
await emit("search", "results", await res.json());
},
});
await store.whenIdle(); // let the work in flight settle
store.dispose();See Tying resources to the store and Load, and waiting for it to finish.
#Request and reply
store.call() gains cancel: an event the call emits when it gives up (cancelled, aborted by its
signal, or timed out), with a CallCancellation payload of
requestId, reason and detail, so the responder can stop work nobody is waiting for. It is
emitted only when the request was actually sent, never after a terminal reply or on disposal, and
it never throws into the caller.
type EM = {
job: { start: { id: string }; done: { ok: boolean }; cancel: CallCancellation };
};
const call = store.call("job", "start", { id }, {
reply: ["job", "done"],
cancel: ["job", "cancel"],
});correlation chooses how a reply is matched: "either" (the default, unchanged), "causal", or
"id", for a responder whose own protocol keeps several requests in flight on one channel, where
the parent link can point at the wrong request. A call whose signal is already aborted no longer
sends its request. See Telling the responder you gave up
and When the reply cannot be a direct child.
#Diagnostics and observability
Every failure the store contains, every refusal (a cascade, a declined write) and every
development warning is now a Diagnostic with a stable code. The owner
routes them with diagnostics on createStore, which replaces the console output; without it,
the console output is unchanged. Code attached later observes the same diagnostics with
store.onDiagnostic, without silencing anything. The on* hooks still fire.
const store = createStore({
name: "app",
reducer: { /* ... */ },
diagnostics: (d) => logger[d.level](d.code, d.message, d.detail),
});Around it, the store reports more of what it does:
store.instrumentEffects(observer)reports each effect's name, origin, duration and failure once every effect for an event has settled. Nothing is timed while no observer is registered.InstrumentedEvent.atis the clock time the event was processed, and itseventcarriesparentIdanddepth, so a tracer no longer takes its own timestamp or loses the causal chain.store.metrics()returnsqueueDepth,inFlightEffects,dedupHitsanddedupEntries.matchesWhenanddescribeWhenProblemexport the store's own matcher and validator.
See Errors and diagnostics, Observing the effect phase and Matching outside the store.
#Time and scheduling
A store now reads the time through a clock and arms every timer through a scheduler, both
options of createStore. The clock decides deduplication windows and stamps
InstrumentedEvent.at; the scheduler arms the deduplication cache prune and the idle timeout of
store.call(). persist takes a scheduler too. A test can own time without faking globals, and
the defaults look the globals up at each use, so vi.useFakeTimers() still works when installed
after the store was built.
let now = 1_000;
const store = createStore({ name: "test", reducer: { counter }, dedupWindowMs: 50, clock: { now: () => now } });See Time and timers and Controlling time.
#Persistence
persist never writes a partial state. Encoding stops at maxNodes values (new on
PersistOptions, 100 000 by default); state past that is not written at all, storage keeps its
previous complete snapshot, and onError receives a PersistEncodeError
with truncated: true and written: false. persist also writes only after an event that changed
state, and its stop function now returns a promise that resolves once the last write has settled.
const stop = persist(store, { key: "app", adapter, version: 3, onError: (error, phase) => report(phase, error) });
await stop(); // flush, and wait for the final write
store.dispose();See Saving and restoring state.
#High-frequency values and ephemeral channels
Some events are traffic, not history: a presence ping, a pointer position, a progress tick. Name
those channels ephemeral. Reducers, subscribers and effects handle them as before, but they reach
only observers registered with store.instrument(observer, { ephemeral: true }), cost no
instrumentation work while none is, and replay skips them. The DevTools agent does not opt in;
persist does. warnOnLargeValues(store, limits?) is a separate, development-only import that
warns once when a payload or a slice grows past a limit in values or bytes.
const store = createStore({ name: "app", reducer: { /* ... */ }, ephemeral: ["presence"] });
if (import.meta.env.DEV) warnOnLargeValues(store);The docs now describe the whole pattern for values that change many times a second. See Traffic that is not history, Values that change many times a second and Values that grew too large.
#Binary values
A typed array, a DataView or an ArrayBuffer in state is now one value at its own path,
compared by reference, as Map and Set already were. Replacing a 4-byte Uint8Array used to
report four paths, one per byte; 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 a view cannot be frozen, so a later write into the buffer changes the
slice unseen.
#Stricter matchers and per-slice types
A reducer or an effect registered with when: { channelPattern } now throws, naming the
registration. channelPattern was only ever honoured by middleware; on a reducer or an effect it
handled nothing. ReducerSpec.when and EffectSpec.when are typed ExactWhen,
so TypeScript reports the mistake first, and a malformed when such as {} throws on every seam.
// Before: accepted, and the reducer never ran.
store.registerReducer("plans", { state, when: { channelPattern: "*plan" }, reducer });
// After: name the channels the slice folds.
store.registerReducer("plans", { state, when: { channels: ["plan", "bb::plan"] }, reducer });replaceReducers and hotReplace({ reducer }) take a ReducerReplacement:
every slice optional, each typed with its own state. The key-collision and dotted-key warnings are
kept per store and name it.
#DevTools
- The browser extension inspects a page without a hub. A content script and a service worker carry frames between the page and the panel, which runs the protocol's in-memory broker. A hub remains available for remote sessions. See How It Works.
- Several stores in one page work through the bridge, each with its own connection and only the commands that name it. A duplicate store id is refused with a handshake error naming it.
- The hub can require a token. The hub and the terminal CLI take it from
--tokenorYOLTRA_DEVTOOLS_TOKEN; the panels and each store agent present it asauthToken. See CLI Options. useStoreStateno longer freezes on a first snapshot at version 0, and the terminal CLI's Emit tab lets you type its shortcut keys into a field.
#React
The Suspense hooks no longer serve a value loaded from state that has since changed: a change
landing between a render and the store subscription used to leave a stale value on screen.
useAtomicProp(slice) without a path accessor now throws a sentence naming both calling forms.
See Suspense Hooks.
#Upgrading
Read the migration guide first. Four changes alter behaviour:
- A reducer or an effect with
channelPatternnow throws, at registration, so it shows up at startup. - A disposed store is inert.
- Persistence never writes a partial state.
- A typed array in state changes as one value.
Test helpers may need a touch: an EffectFunction called by hand takes a context, an
InstrumentedEvent built by hand needs at, and a PersistableStore you implement should accept
the optional second argument to instrument. Every change, package by package, is in the
release notes.