Changes since 0.24.2.
Added
reaktiv.subtle: low-level primitives for framework authors, modelled on
the TC39Signal.subtlenamespace.Watcher(notify), withwatch(),unwatch(),get_pending()and
dispose(), tells you when watched signals may have changed without
evaluating anything, so you can run work on your own schedule. Reading or
writing signals insidenotifyraises, also throughuntracked. See
docs/api/subtle.mdfor the scheduling recipe and the deliberate
differences from TC39.current_consumer()returns the computation or effect currently
tracking reads.signal_version(node)returns a node's revision without reading it.
equal=for model fields and resources:field(default, equal=...),
field(factory=..., equal=...),resource(params=..., loader=..., equal=...)
andresource[Params, Value](params, equal=...). ReturningTruekeeps the
previous value and skips notifications.resource.cancel(): cancels the running load, keeps the previous value,
and sets the new statusResourceStatus.CANCELLED. Returns whether a load was
running.resource.dispose(): stops a resource permanently. It cancels a running
load, stops reacting to parameter changes and makesreload()a no-op.@linked(computation=...)decorator: the decorated function is the source.
On aReactiveModelmethod the computation receives the owner first:
computation(self, source, previous).- Guide for coding agents:
reaktiv/AGENTS.mdships in the package (so it
matches the installed version) and is published in the docs, with an
llms.txtindex. It covers the mental model, which primitive to use, rules
and pitfalls; its examples run in the test suite and type-check with
pyright and mypy. py.typed: the package is marked as typed, so mypy and other checkers
use reaktiv's annotations instead of treating it as untyped. The public API
is fully annotated (pyright --verifytypes: 100%).ReactiveModel.own(obj): registers an object withdispose()or
destroy()so thatmodel.dispose()cleans it up (in reverse registration
order, once each). It returnsobj. On a disposed model the object is
disposed immediately, and so are members first accessed after
model.dispose(), so nothing registered late waits for a cleanup that never
comes.
Changed
These may require changes when upgrading:
- Computed and linked signals keep their previous value when
equal()
returnsTrue, as signals already did and as Angular and SolidJS do. Before,
they stored the new value without notifying, so a fresh read and a dependent
computation could disagree, and a tolerance compared against the latest value
could drift without ever notifying. Equality is now always checked against the
last published value. - Computed and linked signals memoize errors, as Angular and Preact do. A
failing computation runs once; later reads re-raise the same exception
without recomputing until a dependency changes. Before, it reran on every
read. - An
equal()function that raises is no longer ignored.set()and
update()raise and keep the old value. In a computed or linked signal the
exception becomes the node's error, raised on read. When a resource publishes a
loaded value, it becomes the resource's error (statusERROR). Before, the
exception was logged and the values were treated as different. - Linked writes apply pending source changes first, as Angular's
linkedSignaldoes.update()receives the up-to-date value (before,None
before the first read and a stale value after a source change), and a value
written withset()is no longer replaced by the next read.set()also
replaces an error state, andupdate()raises it. - Async effects must be created inside a running event loop and raise
RuntimeErrorotherwise, like resources. Before, such an effect never ran
and was silently stuck. - Writes made by an async effect are no longer batched until it finishes.
They notify immediately, like writes outside an effect. model.dispose()attempts every cleanup even if one raises, then raises
the first exception (further ones are printed). Before, it stopped at the
first failure and kept the remaining objects registered.- Computed signals are no longer
Signalsubclasses.Signal(and
LinkedSignal) are writable;ComputeSignalderives from a shared internal
base and has noset()orupdate()at all, so type checkers reject a
computed whereWritableSignalis required, and calling either method raises
AttributeError. Before,computed.update()was inherited and replaced the
computed value.isinstance(x, Signal)is nowFalsefor computed and linked signals.
Checkisinstance(x, (Signal, ComputeSignal))for "any signal node", or
annotate withReadableSignal[T]/WritableSignal[T].linked(source=...)accepts anyReadableSignal, including computed and
read-only signals, for type checkers too.
- An effect that writes a signal it read runs again once its current run
ends, as in Preact. Before, synchronous effects dropped that change and never
saw the new value. A synchronous effect that keeps changing its own inputs
raisesRuntimeErrorafter 100 iterations of one flush, also when another
effect started the loop (that error used to be swallowed); effects queued at
that point stay runnable. Async effects are not bounded this way. - Model members live in the instance
__dict__(see Fixed).copy.copy()
of a model now shares its signals with the original, andcopy.deepcopy()
andpickleraiseTypeError. Before, both "succeeded" by silently
dropping all reactive state (the copy got fresh default values). PreviousState[S, T]has separate source and value types, as in
Angular'slinkedSignal<S, D>:previous.sourceis the source typeSand
previous.valuethe linked value typeT. Before, both used one type, so
previous.sourcewas typed as the value.PreviousState[X]still works and
means both areX; annotations whose source and value types differ now need
two arguments.linked(source=..., computation=...)and
@linked(computation=...)inferSfrom the source.- The computation of
linked(source=..., computation=...)cannot write
signals. It runs without tracking reads, which used to switch off the write
check, so it could write. An explicituntracked()block inside a computed
still may write, as in Angular (for example to initialize a signal lazily). ResourceSnapshotis frozen. Assigning to a snapshot's fields raises
FrozenInstanceError. Before, a snapshot was a shared cached object, so one
reader's assignment changed what every later reader saw.resource.reload(),cancel()anddispose()raiseRuntimeError
inside a computed or aWatchernotification, likeset(). The check runs
before any state change.computed[T](...)andlinked[T](...)type-check their callback. The
function must returnT(for methods,(self) -> T); before, any return
type was accepted and cast, socomputed[int](lambda: "text")passed
Pyright.linked[T](computation=...)is unchanged: there the decorated
function is the source.ResourceStatushas a new member,CANCELLED. Code that matches every
status needs a case for it.Signal.update()and linked signals'set()/update()inside a computed
now raiseRuntimeError, the same asSignal.set()always has. Before,
they skipped this check.- Linked signals pass
previouswhenever there was a previous run. A
previous value ofNoneused to be treated as "no previous state";
computations now receivePreviousState(value=None, ...). linked(computation=...)without a source returns a decorator instead of
raisingValueError, likelinked(equal=...)already did.resource.destroy()is now an alias ofdispose(). It also stops
parameter tracking and cancels the running load's task; before, it only
signalled cancellation to the loader.reaktivrequirestyping-extensions>=4.11on Python below 3.13 (for the
PreviousStatetype parameter default), and the package now includes the
LICENSEfile.
Fixed
- A destroyed resource no longer starts new loads when its parameters change.
- When a computed's direct dependency is known to have changed, it no longer
re-evaluates other dependencies that the new run may not read, for example a
replaced implementation behind a stable slot. model.dispose()disposes every registered object, even when two of them
compare equal with==.- Distinct model instances that compare equal no longer share fields, computed
values, linked signals, effects or resources. - A suspended async effect no longer delays every other effect on its thread
until it finishes. Disposing a running async effect cancels it immediately
(from another thread, the cancellation runs on the effect's event loop), a
dependency change during an async run triggers a rerun, and cancellation
runs each cleanup once. - Resource transitions (loading, resolved, error, local) are published at
once, so observers never see a mix such asresolvedwhile still loading. - A resource write rejected inside a computed no longer cancels the running
load.resource.update()no longer subscribes the caller to the resource. - An async effect or resource load triggered by a write on another thread runs
on the event loop the effect or resource was created on, and cancelling the
superseded load happens there too. A change queued from another thread is
dropped if a newer change,set(),reload()orcancel()came first. Before, the effect was stuck permanently,
and the resource stayedloadingor failed to start the new load. - A rejected
resource.dispose()no longer leaves the resource marked
disposed with its load still running, so it can be retried. - After
resource.reload(), the next parameter change reportsLOADING
instead ofRELOADING, and the watcher no longer treats unchanged
parameters as a reload request. - A resource whose
LOADINGobserver disposes or cancels it, or writes a local
value, no longer starts the loader anyway. to_async_iter()delivers values written on another thread on the
iterator's event loop, in write order. Before, such a write could leave a
waiting consumer suspended forever. Its docstring now states that values are
buffered without a bound.resource.valuereturns the same signal on every access.- Concurrent
update()calls on a linked signal from several threads no longer
lose writes: refreshing, calling the updater and writing are one atomic
operation, as insignal.update(). - A model is garbage collected even when its own reactive members reference
it, for example a computed returningselfor an effect cleanup closing over
it. Fields, computed and linked signals, effects and resources are now stored
in the instance__dict__(likefunctools.cached_property); only instances
without a__dict__use a weak identity map, which cannot release such cycles. - A subclass that overrides an eager model member (such as an
@effect) no
longer also runs the base class's member.
Full Changelog: 0.24.2...0.25.0