From 5910fc4af7e9b533f1e9f12173c38bc282bbd7c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 23:41:20 -0400 Subject: [PATCH] =?UTF-8?q?test(ui):=20React=20parity=20=E2=80=94=20child?= =?UTF-8?q?=20state=20isolation=20+=20automatic=20bailout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports React's ReactIncremental state-preservation / bailout behavior: - a child component keeps its own state across a parent re-render - a parent re-render that does not change the child's props bails out of re-rendering the child (GWC auto-memoizes by prop equality — no React.memo wrapper needed) Verified via the native reconciler harness. All pass; no GWC bug. No version bump. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/REACT_TEST_PARITY.md | 4 +- ui/state_isolation_native_test.go | 93 +++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 ui/state_isolation_native_test.go diff --git a/docs/REACT_TEST_PARITY.md b/docs/REACT_TEST_PARITY.md index e716f548..85de2f99 100644 --- a/docs/REACT_TEST_PARITY.md +++ b/docs/REACT_TEST_PARITY.md @@ -65,8 +65,8 @@ re-renders/effects are observable inline. SSR behavior is tested via | ReactFragment / ReactTopLevelFragment | fragments hoist; keyed state preserved | ✅ | ui/fragment_native_test.go | | ReactIncrementalSideEffects | mount/unmount side effects | 🔶 | ui/effects_native_test.go (partial) | | ReactNewContext / ReactContextPropagation | context propagation | ✅ | ui/context_native_test.go | -| ReactMemo | memoization / bailout | ⬜ | | -| ReactIncrementalUpdates | batched/sequenced updates | ⬜ | | +| ReactMemo | memoization / bailout | 🔶 | ui/state_isolation_native_test.go — GWC auto-bails a child whose props are unchanged (no React.memo wrapper needed); explicit memo wrapper N/A | +| ReactIncrementalUpdates | batched/sequenced updates, state isolation | ✅ | ui/state_isolation_native_test.go (child state preserved across parent re-render), ui/transition_native_test.go (multi-update) | | ErrorBoundaryReconciliation | error boundary recovery | ✅ | internal/runtime/error_boundary_test.go | | keyed reconciliation (various) | keyed move/insert/remove/duplicate | ✅ | ui/reconciler_native_test.go, reconciler_duplicate_key_test.go (found v3.4.8) | | ReactClass* / ReactAct* / ReactExpiration / ReactLazy / Scheduler | — | 🚫 | concurrent/class/act — no analog | diff --git a/ui/state_isolation_native_test.go b/ui/state_isolation_native_test.go new file mode 100644 index 00000000..788ce490 --- /dev/null +++ b/ui/state_isolation_native_test.go @@ -0,0 +1,93 @@ +//go:build !js || !wasm + +package ui_test + +import ( + "fmt" + "testing" + + "github.com/monstercameron/GoWebComponents/internal/platform/mockdom" + "github.com/monstercameron/GoWebComponents/internal/runtime" +) + +// childTextByMarker finds the text of the node carrying data-r="child". +func childTextByMarker(parseA *mockdom.MockDOMAdapter, parseRoot runtime.DOMNode) string { + var parseWalk func(parseN *mockdom.MockDOMNode) string + parseWalk = func(parseN *mockdom.MockDOMNode) string { + if parseN == nil { + return "" + } + if parseN.Attrs["data-r"] == "child" { + return parseN.TextContent + } + for _, parseC := range parseN.Children { + if parseR := parseWalk(parseC); parseR != "" { + return parseR + } + } + return "" + } + for _, parseK := range parseA.GetChildren(parseRoot) { + if parseN, parseOk := parseK.(*mockdom.MockDOMNode); parseOk { + if parseR := parseWalk(parseN); parseR != "" { + return parseR + } + } + } + return "" +} + +// Ported from React's ReactIncremental / state-preservation and bailout tests: +// a child component keeps its own state across a parent re-render, and a parent +// re-render that does not change the child's props bails out of re-rendering the +// child (automatic memoization). Changing the child's props re-renders it. +func TestChildStateIsolationAndBailout(t *testing.T) { + adapter := mockdom.NewMockDOMAdapter() + rt := runtime.NewRuntime(runtime.Config{DOMAdapter: adapter, Reset: true}) + root := adapter.CreateElement("div") + + var parseSetParent, parseSetChild func(any) + parseChildRenders := 0 + + parseChild := func(parseProps map[string]any) *runtime.Element { + parseChildRenders++ + parseLabel, _ := parseProps["label"].(string) + parseGet, parseSet := runtime.GoUseState(rt, 100) + parseSetChild = parseSet + return runtime.CreateElement("span", map[string]any{"data-r": "child"}, + fmt.Sprintf("%s:%v", parseLabel, parseGet())) + } + parseParent := func() *runtime.Element { + parseGet, parseSet := runtime.GoUseState(rt, 0) + parseSetParent = parseSet + return runtime.CreateElement("div", map[string]any{}, + runtime.CreateElement("span", map[string]any{}, fmt.Sprint(parseGet())), + runtime.CreateElement(parseChild, map[string]any{"key": "c", "label": "L"})) + } + + rt.RenderInto(root, runtime.CreateElement(parseParent, map[string]any{})) + if parseChildTextByMarker := childTextByMarker(adapter, root); parseChildTextByMarker != "L:100" { + t.Fatalf("mount child = %q, want L:100", parseChildTextByMarker) + } + parseRendersAfterMount := parseChildRenders + + // Child updates its own state. + parseSetChild(200) + if parseGot := childTextByMarker(adapter, root); parseGot != "L:200" { + t.Errorf("after setChild = %q, want L:200", parseGot) + } + + // Parent re-renders without changing the child's props: the child keeps its + // state AND is not re-rendered (bailout). + parseRendersBeforeParent := parseChildRenders + parseSetParent(5) + if parseGot := childTextByMarker(adapter, root); parseGot != "L:200" { + t.Errorf("after setParent the child state was lost: %q, want L:200", parseGot) + } + if parseChildRenders != parseRendersBeforeParent { + t.Errorf("child re-rendered on an unrelated parent update (no bailout): %d -> %d", + parseRendersBeforeParent, parseChildRenders) + } + + _ = parseRendersAfterMount +}