Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 12 additions & 12 deletions examples/public-examples-site/assets/docs/latest-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<input>`
gets a `value`, `<textarea>` gets text content, and `<select>` marks the matching
`<option selected>` — matching React and completing the controlled-input SSR work
(v3.4.9 textarea, v3.4.10 select). It is checked by the blocking
`tools/changelogcheck` release gate before a versioned release can ship.
The current latest changelog section is `v3.5.3 - 2026-06-25`. **Fixed:** a
render-phase state update (a component calling its own `setState` during render —
the derived-state pattern) left the DOM showing a value the component never
actually rendered and never converged. `renderFunctionComponent` now detects a
render-phase update (via 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 — matching React's "keeps
restarting until there are no more new updates." Found while bug-hunting GWC's
state management against React. It is checked by the blocking `tools/changelogcheck`
release gate before a versioned release can ship.

See the repository root `CHANGELOG.md` for the full entry body.
9 changes: 9 additions & 0 deletions internal/runtime/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,15 @@ func GoUseState[T any](parseRt *Runtime, parseInitialValue T) (func() T, func(an
if parseTargetFiber == nil {
parseTargetFiber = parseFiber
}
// Render-phase update: the setter was called while its own fiber's
// component function is executing. Don't schedule a commit of the
// half-rendered output — flag the fiber so renderFunctionComponent
// re-runs to convergence with the new state (matching React). The new
// value is already stored above, so the re-run observes it.
if parseRt != nil && parseRt.activeRenderFiber == parseTargetFiber {
parseTargetFiber.renderPhaseUpdate = true
return
}
parseRt.ScheduleOwnedFiberUpdateWithOrigin(parseTargetFiber, parseUpdateOrigin)
}

Expand Down
50 changes: 50 additions & 0 deletions internal/runtime/hot_reload.go
Original file line number Diff line number Diff line change
Expand Up @@ -865,8 +865,10 @@ func (parseRt *Runtime) renderFunctionComponent(parseFiber *Fiber) (*Element, bo
var isHandledPanic bool
var parseNextFromBoundary *Fiber
renderStart := time.Now()
parseRt.activeRenderFiber = parseFiber
func() {
defer SetCurrentFiber(nil)
defer func() { parseRt.activeRenderFiber = nil }()
defer func() {
if parseRecovered := recover(); parseRecovered != nil {
var isHandled bool
Expand Down Expand Up @@ -905,6 +907,54 @@ func (parseRt *Runtime) renderFunctionComponent(parseFiber *Fiber) (*Element, bo
return nil, true, parseNextFromBoundary
}

// Render-phase convergence: if the component updated its own state during
// this render (parseFiber.renderPhaseUpdate, set by the hook setter), re-run
// it with the new state until it stops updating — bounded like React's
// re-render limit — so the committed output reflects the final state instead
// of a half-rendered intermediate.
for parseRPIters := 0; parseFiber.renderPhaseUpdate; parseRPIters++ {
parseFiber.renderPhaseUpdate = false
if parseRPIters >= 25 {
ReportDiagnosticWithContext("runtime", DiagnosticError,
"render-phase state updates did not converge after 25 attempts; likely an unconditional setState during render",
diagnosticPathForFiber(parseFiber), diagnosticComponentStack(parseFiber))
break
}
if parseFiber.hooks != nil {
parseFiber.hooks.index = 0
parseFiber.hooks.stateIndex = 0
parseFiber.hooks.depIndex = 0
parseFiber.hooks.memoIndex = 0
parseFiber.hooks.callbackIndex = 0
parseFiber.hooks.refIndex = 0
parseFiber.hooks.idIndex = 0
parseFiber.hooks.fetchIndex = 0
parseFiber.hooks.funcIndex = 0
parseFiber.hooks.atomIndex = 0
parseFiber.hooks.cleanupIndex = 0
parseFiber.hooks.signature = parseFiber.hooks.signature[:0]
}
if parseFiber.effects != nil {
parseFiber.effects = parseFiber.effects[:0]
}
SetCurrentFiber(parseFiber)
parseRt.activeRenderFiber = parseFiber
func() {
defer SetCurrentFiber(nil)
defer func() { parseRt.activeRenderFiber = nil }()
switch parseFn := parseFiber.typeOf.(type) {
case func() *Element:
parseElement = parseFn()
case func(map[string]any) *Element:
parseElement = parseFn(parseFiber.props)
case func(Attrs) *Element:
parseElement = parseFn(Attrs(parseFiber.props))
case *ComponentType:
parseElement = parseFn.Render(parseFiber.props)
}
}()
}

if parseAttempt == 0 && parseRestore != nil {
if !componentSnapshotSerializableCompatible(parseRestore, parseFiber) {
reportHotReloadFallbackDiagnostic(parseRestore, parseFiber)
Expand Down
10 changes: 8 additions & 2 deletions internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ type Runtime struct {
scheduler Scheduler
browserState BrowserState

wipRoot *Fiber
currentRoot *Fiber
wipRoot *Fiber
currentRoot *Fiber
// activeRenderFiber is the fiber whose component function is executing right
// now (set only by renderFunctionComponent around the render call). A hook
// setter that sees its owner == activeRenderFiber is a render-phase update and
// converges in renderFunctionComponent instead of scheduling a commit. Unlike
// the ambient currentFiber, this is never set by unit tests or the SSR path.
activeRenderFiber *Fiber
nextUnitOfWork *Fiber
deletions []*Fiber
pendingEffectFibers []*Fiber
Expand Down
6 changes: 5 additions & 1 deletion internal/runtime/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ type Fiber struct {
portalUnresolved bool // portal target selector did not resolve at commit; retry on the next commit
needsChildReconcile bool
needsChildOrder bool
// 6 bytes padding here to align next 8-byte field
// renderPhaseUpdate is set when a hook setter is called while this fiber is
// rendering (a render-phase update). renderFunctionComponent re-runs the
// component to converge on the new state instead of committing an output that
// does not match the latest state.
renderPhaseUpdate bool

// Component info
hooks *Hooks
Expand Down
95 changes: 95 additions & 0 deletions ui/render_phase_update_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//go:build !js || !wasm

package ui_test

import (
"fmt"
"testing"

"github.com/monstercameron/GoWebComponents/internal/platform/mockdom"
"github.com/monstercameron/GoWebComponents/internal/runtime"
)

// Ported from React's ReactHooksWithNoopRenderer render-phase-update tests
// ("keeps restarting until there are no more new updates"). When a component
// updates its own state during render, GWC re-runs the component to convergence
// so the committed output reflects the final state — not a half-rendered
// intermediate. Regression test for the v3.5.3 fix (previously the DOM showed a
// value the component never actually rendered).
func TestRenderPhaseUpdateConverges(t *testing.T) {
adapter := mockdom.NewMockDOMAdapter()
rt := runtime.NewRuntime(runtime.Config{DOMAdapter: adapter, Reset: true})
root := adapter.CreateElement("div")

var parseSeen []int
parseRenders := 0
rt.RenderInto(root, runtime.CreateElement(func() *runtime.Element {
parseRenders++
parseGet, parseSet := runtime.GoUseState(rt, 0)
parseSeen = append(parseSeen, parseGet())
if parseGet() < 3 {
parseSet(parseGet() + 1)
}
return runtime.CreateElement("span", map[string]any{}, fmt.Sprint(parseGet()))
}, map[string]any{}))

parseNode, _ := adapter.GetChildren(root)[0].(*mockdom.MockDOMNode)
if parseNode == nil || parseNode.TextContent != "3" {
t.Errorf("render-phase update did not converge: domText = %q, want 3 (seen %v, renders %d)",
func() string {
if parseNode == nil {
return "<nil>"
}
return parseNode.TextContent
}(), parseSeen, parseRenders)
}
// Each render observed the incrementing value; the final committed output
// matches the final render.
if len(parseSeen) == 0 || parseSeen[len(parseSeen)-1] != 3 {
t.Errorf("expected the last render to observe state 3, seen = %v", parseSeen)
}
}

// A conditional render-phase update (derived state) is the common, supported
// pattern: set only when a prop/state differs, converging in one extra pass.
func TestRenderPhaseDerivedStateOneStep(t *testing.T) {
adapter := mockdom.NewMockDOMAdapter()
rt := runtime.NewRuntime(runtime.Config{DOMAdapter: adapter, Reset: true})
root := adapter.CreateElement("div")

parseRenders := 0
rt.RenderInto(root, runtime.CreateElement(func() *runtime.Element {
parseRenders++
parseGet, parseSet := runtime.GoUseState(rt, "init")
if parseGet() == "init" {
parseSet("derived")
}
return runtime.CreateElement("span", map[string]any{}, parseGet())
}, map[string]any{}))

parseNode, _ := adapter.GetChildren(root)[0].(*mockdom.MockDOMNode)
if parseNode == nil || parseNode.TextContent != "derived" {
t.Errorf("derived-state render = %v, want derived", parseNode)
}
if parseRenders != 2 {
t.Errorf("expected 2 renders (initial + one convergence), got %d", parseRenders)
}
}

// An unconditional render-phase update must not hang — the convergence loop is
// bounded and gives up after the cap.
func TestRenderPhaseUnconditionalDoesNotHang(t *testing.T) {
adapter := mockdom.NewMockDOMAdapter()
rt := runtime.NewRuntime(runtime.Config{DOMAdapter: adapter, Reset: true})
root := adapter.CreateElement("div")

// Must return (not hang); we don't assert the value, only that it terminates.
rt.RenderInto(root, runtime.CreateElement(func() *runtime.Element {
parseGet, parseSet := runtime.GoUseState(rt, 0)
parseSet(parseGet() + 1)
return runtime.CreateElement("span", map[string]any{}, fmt.Sprint(parseGet()))
}, map[string]any{}))
if len(adapter.GetChildren(root)) == 0 {
t.Fatal("nothing rendered")
}
}