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
4 changes: 2 additions & 2 deletions docs/REACT_TEST_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
93 changes: 93 additions & 0 deletions ui/state_isolation_native_test.go
Original file line number Diff line number Diff line change
@@ -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
}