From 8900d25cfe52369943f2bbda671b8dc6c9457e2d Mon Sep 17 00:00:00 2001 From: Virgil Date: Fri, 3 Apr 2026 20:21:24 +0000 Subject: [PATCH] fix(html): isolate wrapper attrs from shared nodes Co-Authored-By: Virgil --- layout.go | 22 ++++++++++++ node.go | 96 ++++++++++++++++++++++++++++++++++++++++++++++++--- node_test.go | 22 ++++++++++++ responsive.go | 23 ++++++++++++ 4 files changed, 159 insertions(+), 4 deletions(-) diff --git a/layout.go b/layout.go index d0fd95c..abeeeba 100644 --- a/layout.go +++ b/layout.go @@ -161,6 +161,28 @@ func (l *Layout) VariantValid() bool { return l == nil || l.variantErr == nil } +func (l *Layout) cloneNode() Node { + if l == nil { + return (*Layout)(nil) + } + + clone := *l + if l.attrs != nil { + clone.attrs = maps.Clone(l.attrs) + } + if l.slots != nil { + clone.slots = make(map[byte][]Node, len(l.slots)) + for slot, children := range l.slots { + clonedChildren := make([]Node, len(children)) + for i := range children { + clonedChildren[i] = cloneNode(children[i]) + } + clone.slots[slot] = clonedChildren + } + } + return &clone +} + // layout.go: Render produces the semantic HTML for this layout. // Example: NewLayout("C").C(Raw("body")).Render(NewContext()). // Only slots present in the variant string are rendered. diff --git a/node.go b/node.go index 98862ea..2b4f659 100644 --- a/node.go +++ b/node.go @@ -15,6 +15,10 @@ type Node interface { Render(ctx *Context) string } +type cloneableNode interface { + cloneNode() Node +} + // Compile-time interface checks. var ( _ Node = (*rawNode)(nil) @@ -160,21 +164,33 @@ func Attr(n Node, key, value string) Node { case *elNode: t.attrs[key] = value case *ifNode: - Attr(t.node, key, value) + t.node = Attr(cloneNode(t.node), key, value) case *unlessNode: - Attr(t.node, key, value) + t.node = Attr(cloneNode(t.node), key, value) case *entitledNode: - Attr(t.node, key, value) + t.node = Attr(cloneNode(t.node), key, value) case *switchNode: + cloned := make(map[string]Node, len(t.cases)) for caseKey, caseNode := range t.cases { - t.cases[caseKey] = Attr(caseNode, key, value) + cloned[caseKey] = Attr(cloneNode(caseNode), key, value) } + t.cases = cloned case interface{ setAttr(string, string) }: t.setAttr(key, value) } return n } +func cloneNode(n Node) Node { + if n == nil { + return nil + } + if cloner, ok := n.(cloneableNode); ok { + return cloner.cloneNode() + } + return n +} + // node.go: AriaLabel sets the aria-label attribute on an element node. // Example: AriaLabel(El("button"), "Open menu"). func AriaLabel(n Node, label string) Node { @@ -472,6 +488,24 @@ func (n *elNode) renderWithPath(ctx *Context, path string) string { return b.String() } +func (n *elNode) cloneNode() Node { + if n == nil { + return (*elNode)(nil) + } + + clone := *n + if len(n.children) > 0 { + clone.children = make([]Node, len(n.children)) + for i := range n.children { + clone.children[i] = cloneNode(n.children[i]) + } + } + if n.attrs != nil { + clone.attrs = maps.Clone(n.attrs) + } + return &clone +} + // --- escapeHTML --- // escapeHTML escapes a string for safe inclusion in HTML text content. @@ -526,6 +560,16 @@ func (n *ifNode) Render(ctx *Context) string { return "" } +func (n *ifNode) cloneNode() Node { + if n == nil { + return (*ifNode)(nil) + } + + clone := *n + clone.node = cloneNode(n.node) + return &clone +} + // --- unlessNode --- type unlessNode struct { @@ -551,6 +595,16 @@ func (n *unlessNode) Render(ctx *Context) string { return "" } +func (n *unlessNode) cloneNode() Node { + if n == nil { + return (*unlessNode)(nil) + } + + clone := *n + clone.node = cloneNode(n.node) + return &clone +} + // --- entitledNode --- type entitledNode struct { @@ -578,6 +632,16 @@ func (n *entitledNode) Render(ctx *Context) string { return renderNodeWithPath(n.node, ctx, "") } +func (n *entitledNode) cloneNode() Node { + if n == nil { + return (*entitledNode)(nil) + } + + clone := *n + clone.node = cloneNode(n.node) + return &clone +} + // --- switchNode --- type switchNode struct { @@ -604,6 +668,21 @@ func (n *switchNode) Render(ctx *Context) string { return "" } +func (n *switchNode) cloneNode() Node { + if n == nil { + return (*switchNode)(nil) + } + + clone := *n + if n.cases != nil { + clone.cases = make(map[string]Node, len(n.cases)) + for caseKey, caseNode := range n.cases { + clone.cases[caseKey] = cloneNode(caseNode) + } + } + return &clone +} + // --- eachNode --- type eachNode[T any] struct { @@ -658,3 +737,12 @@ func (n *eachNode[T]) setAttr(key, value string) { return Attr(prev(item), key, value) } } + +func (n *eachNode[T]) cloneNode() Node { + if n == nil { + return (*eachNode[T])(nil) + } + + clone := *n + return &clone +} diff --git a/node_test.go b/node_test.go index efa28dd..d9d1d5b 100644 --- a/node_test.go +++ b/node_test.go @@ -753,6 +753,28 @@ func TestAttr_ThroughSwitchNode(t *testing.T) { } } +func TestAttr_ThroughSwitchNode_IsolatedFromSharedCases(t *testing.T) { + ctx := NewContext() + shared := El("div", Raw("content")) + cases := map[string]Node{ + "dark": shared, + "light": shared, + } + + first := Switch(func(*Context) string { return "dark" }, cases) + second := Switch(func(*Context) string { return "light" }, cases) + + Attr(first, "class", "theme") + + if got := first.Render(ctx); got != `
content
` { + t.Fatalf("first switch render = %q, want %q", got, `
content
`) + } + + if got := second.Render(ctx); got != `
content
` { + t.Fatalf("shared switch cases should not leak attrs, got %q", got) + } +} + func TestAttr_ThroughEachNode(t *testing.T) { ctx := NewContext() node := Each([]string{"a", "b"}, func(item string) Node { diff --git a/responsive.go b/responsive.go index b9944a3..a24640a 100644 --- a/responsive.go +++ b/responsive.go @@ -176,6 +176,29 @@ func (r *Responsive) Render(ctx *Context) string { return r.renderWithPath(ctx, "") } +func (r *Responsive) cloneNode() Node { + if r == nil { + return (*Responsive)(nil) + } + + clone := *r + if r.attrs != nil { + clone.attrs = maps.Clone(r.attrs) + } + if r.variants != nil { + clone.variants = make([]responsiveVariant, len(r.variants)) + for i := range r.variants { + clone.variants[i] = r.variants[i] + if r.variants[i].layout != nil { + if layout, ok := cloneNode(r.variants[i].layout).(*Layout); ok { + clone.variants[i].layout = layout + } + } + } + } + return &clone +} + func (r *Responsive) renderWithPath(ctx *Context, path string) string { if r == nil { return ""