From 944650b84796cc67c2a56f68ddb6f8241a118d71 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 01:04:13 -0400 Subject: [PATCH] fix(state): converge render-phase updates instead of committing stale output (v3.5.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A component that updated its own state during render (the derived-state pattern, if x { setState(...) }) stored the new value and scheduled a commit, but the component was never re-run — so the committed DOM showed a value the component had not actually rendered (rendered 0, DOM showed 1) and never converged. renderFunctionComponent now detects a render-phase update via a new Runtime.activeRenderFiber flag (set only around the actual render call, distinct from the ambient currentFiber so the SSR path and hook unit tests that set currentFiber manually are unaffected) and re-runs the component to convergence, bounded at 25 iterations with a diagnostic to guard an unconditional-setState infinite loop. Matches React's "keeps restarting until there are no more new updates." The hook setter, when its owner == activeRenderFiber, marks the fiber renderPhaseUpdate and returns without scheduling (the convergence loop handles re-render). Found bug-hunting GWC's state management against React. Covered by TestRenderPhaseUpdateConverges / DerivedStateOneStep / UnconditionalDoesNotHang; full suite + native/wasm pass, no regression. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 ++++ .../assets/docs/latest-release-notes.md | 24 ++--- internal/runtime/hooks.go | 9 ++ internal/runtime/hot_reload.go | 50 ++++++++++ internal/runtime/runtime.go | 10 +- internal/runtime/types.go | 6 +- ui/render_phase_update_test.go | 95 +++++++++++++++++++ 7 files changed, 198 insertions(+), 15 deletions(-) create mode 100644 ui/render_phase_update_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df1856d..441d38b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## v3.5.3 - 2026-06-25 + +### Fixed + +- **Render-phase state updates left the DOM inconsistent with the render.** When a + component updated its own state during render (`if x { setState(...) }` — the + derived-state-during-render pattern), the setter stored the new value and + scheduled a commit, but the component was never re-run, so the committed DOM + showed a value the component had not actually rendered (e.g. it rendered `0` but + the DOM showed `1`, and it never converged). `renderFunctionComponent` now + detects a render-phase update (the setter's owner is the fiber actively + rendering, tracked by a new `activeRenderFiber` flag — distinct from the ambient + current fiber so it never trips on the SSR path or hook unit tests) and re-runs + the component to convergence, bounded at 25 iterations with a diagnostic to + guard against an unconditional-setState infinite loop. Matches React's + "keeps restarting until there are no more new updates." Covered by + `TestRenderPhaseUpdateConverges` / `TestRenderPhaseDerivedStateOneStep` / + `TestRenderPhaseUnconditionalDoesNotHang`. + ## v3.5.2 - 2026-06-25 ### Fixed diff --git a/examples/public-examples-site/assets/docs/latest-release-notes.md b/examples/public-examples-site/assets/docs/latest-release-notes.md index d8eaa8c7..0ef4a482 100644 --- a/examples/public-examples-site/assets/docs/latest-release-notes.md +++ b/examples/public-examples-site/assets/docs/latest-release-notes.md @@ -3,18 +3,18 @@ This docs-site page mirrors the first section returned by `tools/changelogcheck.LatestEntry(CHANGELOG.md)`. -## v3.5.2 - 2026-06-25 +## v3.5.3 - 2026-06-25 -The current latest changelog section is `v3.5.2 - 2026-06-25`. **Fixed:** -`defaultValue`/`defaultChecked` rendered as inert, browser-ignored attributes in -SSR, so an uncontrolled form field set via `defaultValue` rendered empty -server-side. The SSR serializer (buffered + streaming) now maps `defaultValue` → -the element's controlled value and `defaultChecked` → `checked` (unless the -controlled prop is already set, so an explicit `value` wins). Because it runs -before the controlled-value logic, it flows through every form element: `` -gets a `value`, `