From 77188d5c4e7f72b589d8a5ce66caefe0f4e596f8 Mon Sep 17 00:00:00 2001 From: Naman <72590190+NV404@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:57:54 +0530 Subject: [PATCH] fix: error handling in component rendering Co-authored-by: Naman <72590190+NV404@users.noreply.github.com> --- component.go | 24 ++++++++++++++++++++---- errors.go | 26 ++++++++------------------ gova.go | 4 ++-- gova_test.go | 12 +++++++++++- render.go | 6 +++--- testing.go | 6 +++--- 6 files changed, 47 insertions(+), 31 deletions(-) diff --git a/component.go b/component.go index fcdc5cf..e8e4dea 100644 --- a/component.go +++ b/component.go @@ -3,10 +3,26 @@ package gova import "fmt" type componentNode struct { - node viewNode - renderFn func(*Scope) View - scope *Scope - rendered *viewNode + node viewNode + renderFn func(*Scope) View + scope *Scope + rendered *viewNode + errFallback func(error) View +} + +// renderComponent invokes the component's renderFn. If an ErrorBoundary +// has registered a fallback on this component, panics are recovered and +// the fallback view is returned in place of the panicking subtree. +func renderComponent(c *componentNode, s *Scope) (result View) { + if c.errFallback == nil { + return c.renderFn(s) + } + defer func() { + if r := recover(); r != nil { + result = c.errFallback(toError(r)) + } + }() + return c.renderFn(s) } func (c *componentNode) viewNode() *viewNode { diff --git a/errors.go b/errors.go index f787f38..5b312cf 100644 --- a/errors.go +++ b/errors.go @@ -5,34 +5,24 @@ import "fmt" // ErrorBoundary wraps a child view and catches panics during rendering. // Accepts either a View, a Viewable, or a *componentNode. Viewable values // are wrapped into a component so their Body() call is guarded. +// +// The fallback is registered on the component as a side channel; the +// rendering path consults it on each render (see renderComponent). This +// keeps the call idempotent — invoking ErrorBoundary on the same +// component across re-renders only updates the fallback, instead of +// nesting panic-recovery wrappers around renderFn. func ErrorBoundary(child any, fallback func(err error) View) *viewNode { view := asView(child) if view == nil { return &viewNode{kind: viewKindGroup} } if comp, ok := view.(*componentNode); ok { - return safeRenderComponent(comp, fallback) + comp.errFallback = fallback + return comp.viewNode() } return view.viewNode() } -func safeRenderComponent(comp *componentNode, fallback func(err error) View) *viewNode { - origRenderFn := comp.renderFn - comp.renderFn = func(s *Scope) View { - var result View - func() { - defer func() { - if r := recover(); r != nil { - result = fallback(toError(r)) - } - }() - result = origRenderFn(s) - }() - return result - } - return comp.viewNode() -} - func toError(r any) error { switch v := r.(type) { case error: diff --git a/gova.go b/gova.go index dcc277d..e4755b8 100644 --- a/gova.go +++ b/gova.go @@ -138,7 +138,7 @@ func RunWithConfig(config AppConfig, root View) { mu.Lock() defer mu.Unlock() - newView := comp.renderFn(scope) + newView := renderComponent(comp, scope) newNode := newView.viewNode() comp.rendered = newNode newSpec := toSpecWithScope(newNode, scope) @@ -154,7 +154,7 @@ func RunWithConfig(config AppConfig, root View) { Provide[dialogPresenter](scope, dialogPresenterKey, newPlatformDialogPresenter(w)) Provide(scope, themeStoreKey, config.Theme) - firstView := comp.renderFn(scope) + firstView := renderComponent(comp, scope) firstNode := firstView.viewNode() comp.rendered = firstNode spec := toSpecWithScope(firstNode, scope) diff --git a/gova_test.go b/gova_test.go index 610e8ba..8be73b2 100644 --- a/gova_test.go +++ b/gova_test.go @@ -412,12 +412,22 @@ func TestErrorBoundary(t *testing.T) { comp := panicking.(*componentNode) scope := newScope(context.Background(), nil) - result := comp.renderFn(scope) + result := renderComponent(comp, scope) node := result.viewNode() if node.kind != viewKindText { t.Fatal("expected fallback Text view") } + + // Re-rendering must not nest panic-recovery wrappers; the + // side-channel fallback should still fire cleanly on subsequent + // renders, with renderFn left untouched. + for i := 0; i < 3; i++ { + result = renderComponent(comp, scope) + if result.viewNode().kind != viewKindText { + t.Fatalf("rerender %d: expected fallback Text view", i) + } + } } func TestImageNode(t *testing.T) { diff --git a/render.go b/render.go index a5e7ad7..b57729e 100644 --- a/render.go +++ b/render.go @@ -21,7 +21,7 @@ func toSpecWithScope(node *viewNode, scope *Scope) fyneBridge.ViewSpec { // gets its own child scope so its State(...) keys do not // collide with the caller's. childScope := scope.childScopeFor("body") - rendered := node.componentRef.renderFn(childScope) + rendered := renderComponent(node.componentRef, childScope) return toSpecWithScope(rendered.viewNode(), childScope) } @@ -357,7 +357,7 @@ func childSpecsWithScope(node *viewNode, scope *Scope) []fyneBridge.ViewSpec { func renderSlot(node *viewNode, parentScope *Scope, slotID string) fyneBridge.ViewSpec { if node.componentRef != nil && parentScope != nil { childScope := parentScope.childScopeFor(slotID) - rendered := node.componentRef.renderFn(childScope) + rendered := renderComponent(node.componentRef, childScope) return toSpecWithScope(rendered.viewNode(), childScope) } return toSpecWithScope(node, parentScope) @@ -455,7 +455,7 @@ func navStackSpec(node *viewNode, scope *Scope, theme *Theme) fyneBridge.ViewSpe func resolveNavModifiers(node *viewNode, scope *Scope) *viewNode { cur := node for cur != nil && cur.componentRef != nil && scope != nil { - rendered := cur.componentRef.renderFn(scope) + rendered := renderComponent(cur.componentRef, scope) cur = rendered.viewNode() } return cur diff --git a/testing.go b/testing.go index 90a2f43..9daca59 100644 --- a/testing.go +++ b/testing.go @@ -13,7 +13,7 @@ func TestRender(root View, opts ...TestOption) *RenderedTree { var node *viewNode if comp, ok := root.(*componentNode); ok { - rendered := comp.renderFn(scope) + rendered := renderComponent(comp, scope) node = rendered.viewNode() // Resolve nested components node = resolveTree(node, scope) @@ -30,7 +30,7 @@ func TestRender(root View, opts ...TestOption) *RenderedTree { func resolveTree(node *viewNode, scope *Scope) *viewNode { if node.componentRef != nil { - rendered := node.componentRef.renderFn(scope) + rendered := renderComponent(node.componentRef, scope) return resolveTree(rendered.viewNode(), scope) } for i, child := range node.children { @@ -75,7 +75,7 @@ func (rt *RenderedTree) Rerender() { if rt.component == nil { return } - rendered := rt.component.renderFn(rt.scope) + rendered := renderComponent(rt.component, rt.scope) rt.root = resolveTree(rendered.viewNode(), rt.scope) }