Skip to content

Fast renderer: force_update() sets widgets back to their element values - #69

Closed
maartenbreddels wants to merge 3 commits into
masterfrom
fix/fast-force-update-resyncs-widgets
Closed

maartenbreddels wants to merge 3 commits into
masterfrom
fix/fast-force-update-resyncs-widgets

Conversation

@maartenbreddels

Copy link
Copy Markdown
Contributor

Part of the audit of REACTON_FAST=1 against the default renderer. This is one small PR per finding; this one is D1c.

Problem

With the default renderer, a forced full walk applies every widget element's kwargs again. A forced walk is force_update(), update(), an explicit rc.render(...), or the first render. So a trait changed from the frontend, or from Python, goes back to the element's value. The fast renderer did walk the whole tree in that case, but it still skipped the widget update when the element was the same object as last time. So the changed value stayed, and code that calls force_update() to resync widgets did not work. The README claimed force_update was "faithful to the old behavior"; this was the gap.

Fix

  • A forced walk now also forces the widget updates in the fast reconcile phase (_reconsolidate_forced_walk).
  • Renders triggered by a state change are not forced. They keep skipping unchanged subtrees and widgets; that is the fast renderer's deliberate, faster behavior.
  • _possible_rerender marks a state-triggered render with a per-thread flag. render() keeps its public signature, so code that patches or overrides render(self, element, container=None) keeps working.
  • An explicit render asks for the forced walk inside the render lock, so a render running on another thread cannot undo the request.
  • render() clears the forced flag in its finally, so a forced render that raised does not force the next state render.
  • The default renderer does not read these flags, so its behavior does not change.

Tests

Six new tests. Each one fails on the code it guards against and passes in both modes:

  • force_update() and rc.render(rc.element) set out-of-band values back.
  • A forced render that waits for a state render on another thread. It waits for the "waiting for mutex" log instead of sleeping.
  • A state render after a failed forced render is not forced.
  • A state render after a successful force_update() is not forced.
  • A child component with key "" does not end the forced walk early.

Full suite: 213 passed (default), 216 passed (fast). The thread test passed 30 of 30 runs.

Review

Crossreview with Astra (GPT-6), Opus and GLM. All three approve.

  • Round 1 found three problems, all fixed:
    • the walk request was set before the lock;
    • the flag stayed on after a failed forced render;
    • the new render() keyword broke code that patches render().
  • Round 2: Astra found and reproduced a bug with a child component keyed "". The fix (a root-context check) and the synchronized thread test were then confirmed by Astra as a small fix.

Left out on purpose, because the problems existed before this PR:

  • update() called while a render is running is not forced.
  • force_update() from another thread while a render is running is dropped.

The first version was written by a codex worker; the review fixes are mine.

🤖 Generated with Claude Code

maartenbreddels and others added 3 commits September 29, 2026 11:32
With the default renderer, a forced full walk (force_update(), update(),
an explicit rc.render(...) or the first render) applies every widget
element's kwargs again, so a trait changed from the frontend or from
Python goes back to the element's value. The fast renderer walked the
whole tree in that case, but still skipped the widget update for an
element that was the same object as last time, so the changed value
stayed. Code that calls force_update() to resync widgets did not work.

A forced walk now also forces the widget updates in the reconcile phase.
Renders triggered by state changes are not forced, so they keep skipping
unchanged subtrees and widgets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review found three problems in the first version:
- render() got a keyword argument, so code that patches or overrides
  render(self, element, container=None) failed on every state change.
- A forced render asked for its walk before taking the render lock, so
  the end of a render on another thread could undo it.
- A forced render that raised before reconciliation left the forced flag
  on, so the next state-triggered render also applied all kwargs again.

A render caused by a state change is now marked per thread instead, the
walk is requested under the lock, and the flag is cleared when render()
ends. Two tests cover the lost request and the flag after a failure.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A child component with key "" reconciles its root element with the same
keys as the root element of the render context. The forced flag was
cleared there, so widgets after that child were not updated. Only the
root context now clears it.

The test for a forced render that waits for a state render on another
thread now waits until that render really blocks on the lock, instead
of sleeping, so it cannot pass without testing the race.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@maartenbreddels
maartenbreddels force-pushed the fix/fast-force-update-resyncs-widgets branch from de48208 to c10f2da Compare September 29, 2026 09:33
@maartenbreddels

Copy link
Copy Markdown
Contributor Author

Closing this. It works, and the crossreview approved it, but the benefit is too small for the risk and the cost.

  • Who needs it: only code that calls force_update() or rc.render(rc.element) to "resync" widgets after the frontend or Python changed a trait. We found no such code in our production app. solara only calls rc.render() on app load and hot reload, where the elements are new, so the fast renderer applies them anyway.
  • The risk: it changes shared code in render() (a per-thread flag for state-triggered renders, and the forced-walk request under the lock). It took three review rounds to get that right: a race with another thread, a flag left on after an error, a signature change, and a child component with key "".
  • The cost: force_update() gets slower in fast mode (benchmark force_update_wide 5.3 ms to 9.3 ms), because it updates every widget again.

Instead, we will document this as a known difference in benchmarks/README.md. In fast mode, force_update() walks the whole tree, but it does not re-apply unchanged element kwargs to widgets.

The branch fix/fast-force-update-resyncs-widgets stays, in case someone needs this later.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant