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.

ts
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.

ts
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.

ts
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.at is the clock time the event was processed, and its event carries parentId and depth, so a tracer no longer takes its own timestamp or loses the causal chain.
  • store.metrics() returns queueDepth, inFlightEffects, dedupHits and dedupEntries.
  • matchesWhen and describeWhenProblem export 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.

ts
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.

ts
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.

ts
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.

ts
// 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 --token or YOLTRA_DEVTOOLS_TOKEN; the panels and each store agent present it as authToken. See CLI Options.
  • useStoreState no 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:

  1. A reducer or an effect with channelPattern now throws, at registration, so it shows up at startup.
  2. A disposed store is inert.
  3. Persistence never writes a partial state.
  4. 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.

Report a problem with this page