From d48e31bd9f07ba9fbaa41d437a472e509f9aeeb9 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 21 Aug 2026 10:37:30 +0200 Subject: [PATCH 1/2] feat(ui): render per-element Template attributes --- .agents/skills/jaws/SKILL.md | 8 +++ lib/ui/AI.md | 18 ++++- lib/ui/template.go | 32 ++++++--- lib/ui/template_handler_test.go | 118 ++++++++++++++++++++++++++++++++ lib/ui/template_state_test.go | 10 ++- 5 files changed, 170 insertions(+), 16 deletions(-) diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index b50ba038..7234c69a 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -165,6 +165,12 @@ These are the two usual building blocks for widget handlers passed to `$.Button` - `ui.Template` is for partial templates only; full document/page templates should be rendered through `ui.Handler`. - A nil-interface Template `Dot` is valid and contributes no tag; a typed nil follows its dynamic type's comparability and expansion behavior. +- When a Template `Dot` implements `jaws.InitialHTMLAttrHandler`, its callback supplies + attributes separately for each generated wrapper's initial render. It receives the + concrete Element, so equal Template values may render different per-Element attributes + without putting attribute state in the Template. The callback is not invoked by + `Template.JawsUpdate`; use `Element.SetAttr` and `Element.RemoveAttr` for dynamic wrapper + changes during updates. - The root dot **must** be comparable at runtime and equal to itself: `ui.NewTemplate` returns a value, so the dot is part of the widget the container widgets use as a map key. A slice, map, func or NaN-bearing dot makes the widget unusable as a container child. @@ -233,6 +239,8 @@ Implications: - Non-comparable handlers are not auto-tagged unless they implement `tag.TagGetter`. - Pass explicit tags when dirty targeting depends on them. - HTML attributes passed to `$.Template(...)` are applied to the generated template wrapper. +- Attributes returned by the Dot's `jaws.InitialHTMLAttrHandler` are appended to that + wrapper during its initial render. - Template bodies used with `$.Template(...)` must be partials, not full documents. - For dynamic button text, avoid passing plain static strings if the value must change after render; use getter-based values so updates reflect new state. diff --git a/lib/ui/AI.md b/lib/ui/AI.md index e6b46f84..ed4888b2 100644 --- a/lib/ui/AI.md +++ b/lib/ui/AI.md @@ -70,9 +70,13 @@ available through `rw.NewUI(ui.NewX(...), params...)`. `rw.Template(outerTag, name, dot, params...)` renders a partial template inside a generated addressable JaWS wrapper. An empty outer tag selects `div`; choose a semantic wrapper such as `tr`, `td`, `li`, or `option` when DOM context requires -it. Attributes passed in params apply to the wrapper. Full page templates belong -in `ui.Handler`; static structural inclusion should use Go's native template -action: +it. Attributes passed in params apply to the wrapper. When dot implements +`jaws.InitialHTMLAttrHandler`, its callback also supplies attributes separately +for each wrapper's initial render; equal Template values may therefore render +different Element-specific attributes without storing them in the Template. +The callback is not invoked during `Template.JawsUpdate`. Full page templates +belong in `ui.Handler`; static structural inclusion should use Go's native +template action: ```gotemplate {{template "partial" .Dot}} @@ -133,6 +137,14 @@ does not run initial-attribute hooks. Call `Element.ApplyInitialHTMLAttr` separately and without holding a lock that the callback might acquire. A `bind.Binder` acquires its own value lock before invoking its hook. +A wrapped Template applies this initial-attribute hook to its Dot after claiming +the Element state slot and resolving the named partial. The result is written +directly to that Element's wrapper and is not retained in Template state. An +unwrapped Template has no attribute target and does not invoke the callback. +Wrapper attributes persist when `Template.JawsUpdate` replaces only the inner +HTML; dynamic wrapper changes use `Element.SetAttr` and `Element.RemoveAttr` +during update processing. + Getter paths must not mutate domain state. They may queue wrapper changes with Element update methods so class/attribute changes flush with the HTML update. HTMLInner-backed widgets unconditionally queue `SetInner` when updated; input diff --git a/lib/ui/template.go b/lib/ui/template.go index fbe8ba77..9b15d1d9 100644 --- a/lib/ui/template.go +++ b/lib/ui/template.go @@ -23,10 +23,16 @@ import ( // runtime and equal to itself, and Dot must be usable as a tag under // [tag.TagExpand]. // +// If Dot implements [jaws.InitialHTMLAttrHandler], its callback supplies wrapper +// attributes separately for each wrapped Element's initial render. An unwrapped +// Template has no attribute target and does not invoke the callback. The callback +// is not invoked during [Template.JawsUpdate]. +// // OuterHTMLTag names the wrapper that receives the JaWS ID and render-time HTML -// attributes. An empty field renders without a wrapper, making [Template.JawsUpdate] -// a no-op. [NewTemplate] defaults an empty wrapper argument to "div". The named -// template must be a partial; use [Handler] for a complete document. +// attributes from render parameters and Dot. An empty field renders without a +// wrapper, making [Template.JawsUpdate] a no-op. [NewTemplate] defaults an empty +// wrapper argument to "div". The named template must be a partial; use [Handler] +// for a complete document. // // A Template owns the Elements created through its [RequestWriter]. A successful // update unregisters Elements from the previous execution. Elements created by a @@ -41,7 +47,7 @@ import ( type Template struct { OuterHTMLTag string // Wrapper element; empty renders unwrapped and disables JawsUpdate. Name string // Template name to be looked up using Jaws.LookupTemplate. - Dot any // Template data exposed as With.Dot and expanded for tag registration. + Dot any // Template data, tag source, event delegate, and initial-attribute source. } var ( @@ -155,9 +161,9 @@ func writeTemplateWrapperStart(elem *jaws.Element, w io.Writer, outerHTMLTag str } func (tmpl Template) render(elem *jaws.Element, w io.Writer, params []any) (err error) { - // Claim the state slot before anything observable happens: tag registration, handler - // registration and every write come after, so a contended Element fails having - // changed nothing rather than having half-registered itself. + // Claim the state slot before anything observable happens: application callbacks, + // tag and handler registration, and every write come after, so a contended Element + // fails having changed nothing rather than having half-registered itself. st := &templateState{} if err = jaws.SetElementState(elem, st); err != nil { return @@ -172,6 +178,9 @@ func (tmpl Template) render(elem *jaws.Element, w io.Writer, params []any) (err var lookedUp *template.Template if lookedUp, err = tmpl.lookup(elem); err == nil { if doWrap { + for _, attr := range elem.ApplyInitialHTMLAttr(tmpl.Dot) { + attrs = append(attrs, string(attr)) + } err = writeTemplateWrapperStart(elem, w, tmpl.OuterHTMLTag, attrs) } if err == nil { @@ -283,7 +292,8 @@ func (tmpl Template) JawsInput(elem *jaws.Element, value string) (err error) { // dot may be a nil interface. Otherwise it must make the returned Template // comparable and equal to itself, and it must be usable as a tag under // [tag.TagExpand]. Use the returned Template as a value; taking its address is -// unsupported. +// unsupported. If dot implements [jaws.InitialHTMLAttrHandler], its callback +// supplies attributes separately for each generated wrapper's initial render. func NewTemplate(outerHTMLTag, name string, dot any) Template { if outerHTMLTag == "" { outerHTMLTag = "div" @@ -297,8 +307,10 @@ func newTemplate(outerHTMLTag, name string, dot any) Template { // Template renders the named partial template with dot exposed as [With.Dot]. // -// The generated outerHTMLTag wrapper owns the JaWS ID and HTML attributes in -// params. An empty outerHTMLTag defaults to "div". See [NewTemplate]. +// The generated outerHTMLTag wrapper owns the JaWS ID, HTML attributes in params, +// and attributes returned by dot when it implements +// [jaws.InitialHTMLAttrHandler]. An empty outerHTMLTag defaults to "div". See +// [NewTemplate]. func (rw RequestWriter) Template(outerHTMLTag, name string, dot any, params ...any) error { return rw.NewUI(NewTemplate(outerHTMLTag, name, dot), params...) } diff --git a/lib/ui/template_handler_test.go b/lib/ui/template_handler_test.go index d1458c29..122e4827 100644 --- a/lib/ui/template_handler_test.go +++ b/lib/ui/template_handler_test.go @@ -27,6 +27,25 @@ type templateDot struct { menus int } +type templateInitialAttrDot struct { + mu sync.Mutex + calls []jaws.Jid +} + +func (d *templateInitialAttrDot) JawsInitialHTMLAttr(elem *jaws.Element) template.HTMLAttr { + d.mu.Lock() + d.calls = append(d.calls, elem.Jid()) + d.mu.Unlock() + return template.HTMLAttr(`data-element="` + elem.Jid().String() + `"`) +} + +func (d *templateInitialAttrDot) attrCalls() (calls []jaws.Jid) { + d.mu.Lock() + calls = append(calls, d.calls...) + d.mu.Unlock() + return +} + func (d *templateDot) JawsUpdate(elem *jaws.Element) { d.updated++ } @@ -174,6 +193,75 @@ func TestTemplate_RenderUpdateEventAndHelpers(t *testing.T) { } } +func TestNewTemplate_RendersDotInitialHTMLAttrPerElement(t *testing.T) { + jw, rq := newCoreRequest(t) + if err := jw.AddTemplateLookuper(template.Must(template.New("attrtmpl").Parse(`content`))); err != nil { + t.Fatal(err) + } + + dot := new(templateInitialAttrDot) + first := NewTemplate("article", "attrtmpl", dot) + second := NewTemplate("article", "attrtmpl", dot) + if first != second { + t.Fatal("Templates rebuilt from the same definition are not equal") + } + definitions := map[Template]struct{}{first: {}} + if _, ok := definitions[second]; !ok { + t.Fatal("equal Template is not usable as a map key") + } + + provider := &testContainer{contents: []jaws.UI{first, second}} + containerElem, got := renderUI(t, rq, NewContainer("section", provider)) + children := containerElements(t, containerElem) + if len(children) != 2 { + t.Fatalf("Template children = %d, want 2", len(children)) + } + if templateStateOf(children[0]) == templateStateOf(children[1]) { + t.Fatal("equal Templates share per-Element state") + } + for _, child := range children { + want := `
content
` + if !strings.Contains(got, want) { + t.Errorf("rendered HTML %q does not contain %q", got, want) + } + } + + calls := dot.attrCalls() + if len(calls) != len(children) { + t.Fatalf("JawsInitialHTMLAttr calls = %v, want one per Template Element", calls) + } + for i, child := range children { + if calls[i] != child.Jid() { + t.Errorf("JawsInitialHTMLAttr call %d used Element %v, want %v", i, calls[i], child.Jid()) + } + } + + children[0].JawsUpdate() + if calls = dot.attrCalls(); len(calls) != len(children) { + t.Fatalf("JawsInitialHTMLAttr calls after update = %v, want initial-render calls only", calls) + } +} + +func TestTemplate_RenderCombinesParamAndDotInitialHTMLAttrs(t *testing.T) { + jw, rq := newCoreRequest(t) + if err := jw.AddTemplateLookuper(template.Must(template.New("attrtmpl").Parse(`content`))); err != nil { + t.Fatal(err) + } + + dot := new(templateInitialAttrDot) + elem, got := renderUI(t, rq, NewTemplate("article", "attrtmpl", dot), template.HTMLAttr(`class="param"`)) + if !strings.Contains(got, `class="param"`) { + t.Fatalf("rendered HTML is missing the parameter attribute: %q", got) + } + want := `data-element="` + elem.Jid().String() + `"` + if !strings.Contains(got, want) { + t.Fatalf("rendered HTML is missing the Dot attribute %q: %q", want, got) + } + if calls := dot.attrCalls(); len(calls) != 1 || calls[0] != elem.Jid() { + t.Fatalf("JawsInitialHTMLAttr calls = %v, want [%v]", calls, elem.Jid()) + } +} + func TestTemplate_RenderWithTableRowWrapper(t *testing.T) { jw, rq := newCoreRequest(t) // The native template action includes the structural td fragment without another @@ -412,6 +500,20 @@ func TestTemplate_UpdateLogsMissingTemplate(t *testing.T) { } } +func TestTemplate_RenderMissingTemplateSkipsDotInitialHTMLAttr(t *testing.T) { + _, rq := newCoreRequest(t) + dot := new(templateInitialAttrDot) + tmpl := NewTemplate("div", "missingtemplate", dot) + elem := rq.NewElement(tmpl) + + if err := elem.JawsRender(io.Discard, nil); !errors.Is(err, ErrMissingTemplate) { + t.Fatalf("render error = %v, want %v", err, ErrMissingTemplate) + } + if calls := dot.attrCalls(); len(calls) != 0 { + t.Fatalf("missing Template invoked JawsInitialHTMLAttr for %v", calls) + } +} + func TestNewTemplate_EmptyWrapperDefaultsToDiv(t *testing.T) { tpl := NewTemplate("", "partial", tag.Tag("dot")) if tpl.OuterHTMLTag != "div" { @@ -419,6 +521,22 @@ func TestNewTemplate_EmptyWrapperDefaultsToDiv(t *testing.T) { } } +func TestTemplate_UnwrappedSkipsDotInitialHTMLAttr(t *testing.T) { + jw, rq := newCoreRequest(t) + if err := jw.AddTemplateLookuper(template.Must(template.New("unwrapped").Parse(`content`))); err != nil { + t.Fatal(err) + } + + dot := new(templateInitialAttrDot) + _, got := renderUI(t, rq, Template{Name: "unwrapped", Dot: dot}) + if got != "content" { + t.Fatalf("unwrapped Template rendered %q, want %q", got, "content") + } + if calls := dot.attrCalls(); len(calls) != 0 { + t.Fatalf("unwrapped Template invoked JawsInitialHTMLAttr for %v", calls) + } +} + // TestTemplate_UpdateLogsExecuteError needs a template whose initial render succeeds and // whose later update fails: a wrapped Template updates only an Element it rendered, so a // template that always fails could never establish the state slot to begin with. diff --git a/lib/ui/template_state_test.go b/lib/ui/template_state_test.go index 2ac0427f..4b2c201c 100644 --- a/lib/ui/template_state_test.go +++ b/lib/ui/template_state_test.go @@ -327,8 +327,9 @@ func TestTemplate_SecondClaimRegistersNoHandler(t *testing.T) { // TestTemplate_SecondClaimOnOneElementFails covers the contention path through the // supported shape — one renderer delegating to two Templates — and proves the claim -// precedes every side effect: the rejected Template writes nothing and registers no tag. -// TestTemplate_SecondClaimRegistersNoHandler covers the handler list. +// precedes every side effect: the rejected Template writes nothing, registers no tag +// and invokes no initial-attribute callback. TestTemplate_SecondClaimRegistersNoHandler +// covers the handler list. func TestTemplate_SecondClaimOnOneElementFails(t *testing.T) { for _, tt := range []struct { name string @@ -341,7 +342,7 @@ func TestTemplate_SecondClaimOnOneElementFails(t *testing.T) { _, rq := newStateRequest(t) firstDot := tag.Tag("first") - secondDot := tag.Tag("second") + secondDot := new(templateInitialAttrDot) ui := &contendingUI{ first: NewTemplate("div", "state-plain", firstDot), second: NewTemplate("div", "state-b", secondDot), @@ -368,6 +369,9 @@ func TestTemplate_SecondClaimOnOneElementFails(t *testing.T) { if got := len(rq.GetElements(secondDot)); got != 0 { t.Fatalf("elements tagged by the rejected Template = %d, want 0", got) } + if calls := secondDot.attrCalls(); len(calls) != 0 { + t.Fatalf("rejected Template invoked JawsInitialHTMLAttr for %v", calls) + } if got := len(rq.GetElements(tag.Tag("param"))); got != 0 { t.Fatalf("elements tagged from the rejected render's params = %d, want 0", got) } From b8749721bdd963f7436a3b892ec0708bf3b521d3 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Fri, 21 Aug 2026 10:50:13 +0200 Subject: [PATCH 2/2] docs(ui): define Template attribute precedence --- .agents/skills/jaws/SKILL.md | 6 ++++-- lib/ui/AI.md | 7 ++++--- lib/ui/template.go | 23 ++++++++++++++--------- lib/ui/template_handler_test.go | 7 ++----- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md index 7234c69a..5b129a95 100644 --- a/.agents/skills/jaws/SKILL.md +++ b/.agents/skills/jaws/SKILL.md @@ -168,7 +168,8 @@ These are the two usual building blocks for widget handlers passed to `$.Button` - When a Template `Dot` implements `jaws.InitialHTMLAttrHandler`, its callback supplies attributes separately for each generated wrapper's initial render. It receives the concrete Element, so equal Template values may render different per-Element attributes - without putting attribute state in the Template. The callback is not invoked by + without putting attribute state in the Template. An unwrapped Template has no attribute + target and does not invoke the callback. The callback is not invoked by `Template.JawsUpdate`; use `Element.SetAttr` and `Element.RemoveAttr` for dynamic wrapper changes during updates. - The root dot **must** be comparable at runtime and equal to itself: `ui.NewTemplate` @@ -238,7 +239,8 @@ JaWS parses template params as: Implications: - Non-comparable handlers are not auto-tagged unless they implement `tag.TagGetter`. - Pass explicit tags when dirty targeting depends on them. -- HTML attributes passed to `$.Template(...)` are applied to the generated template wrapper. +- HTML attributes passed to `$.Template(...)` are applied to the generated template wrapper + before Dot attributes and take precedence when a name is duplicated. - Attributes returned by the Dot's `jaws.InitialHTMLAttrHandler` are appended to that wrapper during its initial render. - Template bodies used with `$.Template(...)` must be partials, not full documents. diff --git a/lib/ui/AI.md b/lib/ui/AI.md index ed4888b2..d8d4a9b9 100644 --- a/lib/ui/AI.md +++ b/lib/ui/AI.md @@ -70,9 +70,10 @@ available through `rw.NewUI(ui.NewX(...), params...)`. `rw.Template(outerTag, name, dot, params...)` renders a partial template inside a generated addressable JaWS wrapper. An empty outer tag selects `div`; choose a semantic wrapper such as `tr`, `td`, `li`, or `option` when DOM context requires -it. Attributes passed in params apply to the wrapper. When dot implements -`jaws.InitialHTMLAttrHandler`, its callback also supplies attributes separately -for each wrapper's initial render; equal Template values may therefore render +it. Attributes passed in params apply to the wrapper and take precedence when +the dot callback returns an attribute with the same name. When dot implements +`jaws.InitialHTMLAttrHandler`, its callback supplies attributes separately for +each wrapper's initial render; equal Template values may therefore render different Element-specific attributes without storing them in the Template. The callback is not invoked during `Template.JawsUpdate`. Full page templates belong in `ui.Handler`; static structural inclusion should use Go's native diff --git a/lib/ui/template.go b/lib/ui/template.go index 9b15d1d9..450a2859 100644 --- a/lib/ui/template.go +++ b/lib/ui/template.go @@ -29,10 +29,11 @@ import ( // is not invoked during [Template.JawsUpdate]. // // OuterHTMLTag names the wrapper that receives the JaWS ID and render-time HTML -// attributes from render parameters and Dot. An empty field renders without a -// wrapper, making [Template.JawsUpdate] a no-op. [NewTemplate] defaults an empty -// wrapper argument to "div". The named template must be a partial; use [Handler] -// for a complete document. +// attributes from render parameters and Dot. Render-parameter attributes take +// precedence when Dot returns an attribute with the same name. An empty field +// renders without a wrapper, making [Template.JawsUpdate] a no-op. [NewTemplate] +// defaults an empty wrapper argument to "div". The named template must be a +// partial; use [Handler] for a complete document. // // A Template owns the Elements created through its [RequestWriter]. A successful // update unregisters Elements from the previous execution. Elements created by a @@ -161,9 +162,10 @@ func writeTemplateWrapperStart(elem *jaws.Element, w io.Writer, outerHTMLTag str } func (tmpl Template) render(elem *jaws.Element, w io.Writer, params []any) (err error) { - // Claim the state slot before anything observable happens: application callbacks, - // tag and handler registration, and every write come after, so a contended Element - // fails having changed nothing rather than having half-registered itself. + // Claim the state slot before anything observable happens: Dot expansion, tag and + // handler registration, template lookup, initial-attribute callbacks, and every + // write come after, so a contended Element fails having changed nothing rather + // than having half-registered itself. st := &templateState{} if err = jaws.SetElementState(elem, st); err != nil { return @@ -178,6 +180,8 @@ func (tmpl Template) render(elem *jaws.Element, w io.Writer, params []any) (err var lookedUp *template.Template if lookedUp, err = tmpl.lookup(elem); err == nil { if doWrap { + // HTML parsing keeps the first duplicate attribute, so append Dot + // attributes after render-parameter attributes. for _, attr := range elem.ApplyInitialHTMLAttr(tmpl.Dot) { attrs = append(attrs, string(attr)) } @@ -309,8 +313,9 @@ func newTemplate(outerHTMLTag, name string, dot any) Template { // // The generated outerHTMLTag wrapper owns the JaWS ID, HTML attributes in params, // and attributes returned by dot when it implements -// [jaws.InitialHTMLAttrHandler]. An empty outerHTMLTag defaults to "div". See -// [NewTemplate]. +// [jaws.InitialHTMLAttrHandler]. Attributes in params take precedence when dot +// returns an attribute with the same name. An empty outerHTMLTag defaults to +// "div". See [NewTemplate]. func (rw RequestWriter) Template(outerHTMLTag, name string, dot any, params ...any) error { return rw.NewUI(NewTemplate(outerHTMLTag, name, dot), params...) } diff --git a/lib/ui/template_handler_test.go b/lib/ui/template_handler_test.go index 122e4827..a6fa439d 100644 --- a/lib/ui/template_handler_test.go +++ b/lib/ui/template_handler_test.go @@ -250,12 +250,9 @@ func TestTemplate_RenderCombinesParamAndDotInitialHTMLAttrs(t *testing.T) { dot := new(templateInitialAttrDot) elem, got := renderUI(t, rq, NewTemplate("article", "attrtmpl", dot), template.HTMLAttr(`class="param"`)) - if !strings.Contains(got, `class="param"`) { - t.Fatalf("rendered HTML is missing the parameter attribute: %q", got) - } - want := `data-element="` + elem.Jid().String() + `"` + want := `class="param" data-element="` + elem.Jid().String() + `"` if !strings.Contains(got, want) { - t.Fatalf("rendered HTML is missing the Dot attribute %q: %q", want, got) + t.Fatalf("rendered HTML does not contain ordered attributes %q: %q", want, got) } if calls := dot.attrCalls(); len(calls) != 1 || calls[0] != elem.Jid() { t.Fatalf("JawsInitialHTMLAttr calls = %v, want [%v]", calls, elem.Jid())