OWL — Odoo’s component framework that serves as the rendering layer for every form, list, kanban board, and dashboard in the platform — has shipped a round of changes that collectively represent one of the more significant reactivity engine updates in recent memory. Two new primitives, a redesigned async pattern, and five targeted bug fixes that close classes of silent state corruption.
The Synchronous Gap That immediateEffect Fills
OWL’s existing reactivity model is built on eventual consistency. When you write to a signal, the effects that depend on it are scheduled to re-run in a microtask — not immediately, but before the next frame renders. This works well for UI updates, where batching multiple writes into a single render pass is desirable. But it creates a timing gap for side-effects that need to reflect the new state synchronously, as part of the write itself.
Consider keeping a non-reactive ledger object in sync with a reactive price signal. With a standard effect, the ledger is stale between the moment you call price.set(120)and the moment the microtask runs. Any code that reads the ledger in that window — a validation check, a logging call, an interop layer with non-OWL code — sees the old value.
The new immediateEffect primitive and its component counterpart useImmediateEffectexecute their callback synchronously during the write that invalidated them. The callback runs inside the reactive graph’s notification cycle, which means the ledger is already updated by the timeprice.set() returns. No microtask delay, no stale window.
This is a deliberate trade-off. Immediate effects bypass batching, so a loop that writes a signal 100 times will trigger the immediate effect 100 times rather than once. The documentation is explicit about the intended use case: keeping non-reactive external state synchronized with reactive internal state, not driving UI updates.
asyncComputed.refresh() Becomes Awaitable
The async computed pattern in OWL lets components derive values from asynchronous operations — API calls, database queries, file reads — while the reactivity system tracks which signals the computation depends on and automatically re-runs it when they change. The refresh() method lets you manually trigger a re-computation, but until now it returned void.
That meant there was no way to know when the refreshed value was actually available. Calling data.refresh() kicked off the re-computation, but the calling code had to polldata.loading() or set up a watcher to know when it finished. This is the kind of API gap that leads to race conditions: the refresh is still batched in the effect flush, but the promise was resolving before the refreshed run started if a previous run settled first.
Now refresh() returns the same promise ascurrentPromise(), so await data.refresh() waits for the actual refreshed value. The implementation synchronously marks the computation as in-flight and bumps an internal run ID, preventing the stale-resolve race. Afterdispose(), refresh()returns a resolved promise instead of hanging forever — a detail that matters for component teardown.
Array Length Tracking Was Silently Broken
One of the five reactivity fixes addresses a proxy behavior that most developers would never think to test. When you have a reactive proxy wrapping an array and you write past the end —proxy[2] = 3on a two-element array — the JavaScript engine grows the array and sets the new length behind the scenes, withoutcalling the proxy’s set trap for the length property. Any effect that was tracking proxy.length never learned that the array grew.
This bug was hidden by push(), which explicitly writes length after adding the element. But when an immediate effect (the new synchronous kind) throws during the index write, push stops before writing length, and the observers of proxy.length permanently show the old value. The fix notifies length observers whenever an index write causes the underlying array to grow, regardless of whether length was explicitly written.
useOnChange Survives Writing Its Own Dependencies
A second fix targets a pattern that sounds circular but is legitimate: a useOnChangecallback that writes one of its own tracked dependencies. Imagine a form field that normalizes its own value on change — converting a string to uppercase, trimming whitespace, rounding a number. The callback reads the field value, transforms it, and writes it back.
Before this fix, the callback worked exactly once. The self-write made the internal computed stale, but because the effect was currently running, it wasn’t notified. The effect ended without re-reading the computed, and subsequent writes never propagated through. The callback was permanently dead after its first execution. The fix ensures the effect re-evaluates its dependency computed on the next write cycle, keeping the callback alive.
Computations Keep Notifying After Errors
The third fix is the most architecturally important. When a computed getter, a custom equality function, or an effect threw an exception, the reactive graph would stop propagating notifications through that node permanently. Any downstream effect or component that depended on the throwing computation would never update again, even after the source signals changed to values that wouldn’t cause the throw.
This happened because a throwing computation left internal state flags in an inconsistent position. The notification machinery checked those flags and concluded the node was already up to date, so it skipped it. The fix resets the computation’s internal state on error so that subsequent source changes still trigger re-evaluation. The computed might throw again, but at least it’ll try, and if the underlying data has changed to valid values, the UI recovers.
Select Elements Render Options Before Setting Values
A blockdom-level fix changes the order in which the virtual DOM engine processes element properties and children during mount and patch operations. For <select> elements, the engine was setting the value property before mounting child <option>elements. The browser rejected the value assignment because the target option didn’t exist yet, silently resetting the select to its first option.
This bug was timing-dependent. It only appeared when options were rendered by child components rather than static HTML, because static options were part of the same block and mounted atomically. Dynamic options from child blocks were mounted after property setters ran. The fix reverses the order: children mount first, then property setters execute, so the browser sees valid options when the value is assigned.
The Compound Effect
Each of these changes is defensible on its own, but the compound effect matters more than any individual fix. A reactivity engine that silently drops notifications after errors, permanently kills callbacks that write their own dependencies, loses track of array lengths on index writes, and races async refreshes against stale resolves is an engine where developers learn to distrust reactive state. They add manual subscription management, defensive re-renders, and workaround effects that compensate for the bugs they’ve internalized.
This batch of changes tightens the contract between OWL and its users: effects run when they should, errors are recoverable, promises resolve at the right time, and synchronous side-effects have a first-class primitive instead of requiring timing hacks. That’s the kind of foundation work that makes everything built on top of it more reliable.