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
12 changes: 11 additions & 1 deletion .agents/skills/jaws/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ 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. 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`
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.
Expand Down Expand Up @@ -232,7 +239,10 @@ 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.
- 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.

Expand Down
19 changes: 16 additions & 3 deletions lib/ui/AI.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,14 @@ 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 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
template action:

```gotemplate
{{template "partial" .Dot}}
Expand Down Expand Up @@ -133,6 +138,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
Expand Down
37 changes: 27 additions & 10 deletions lib/ui/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,17 @@ 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. 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
Expand All @@ -41,7 +48,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 (
Expand Down Expand Up @@ -155,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: 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: 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
Expand All @@ -172,6 +180,11 @@ 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))
}
err = writeTemplateWrapperStart(elem, w, tmpl.OuterHTMLTag, attrs)
}
if err == nil {
Expand Down Expand Up @@ -283,7 +296,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"
Expand All @@ -297,8 +311,11 @@ 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]. 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...)
}
115 changes: 115 additions & 0 deletions lib/ui/template_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++
}
Expand Down Expand Up @@ -174,6 +193,72 @@ 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 := `<article id="` + child.Jid().String() + `" data-element="` + child.Jid().String() + `">content</article>`
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"`))
want := `class="param" data-element="` + elem.Jid().String() + `"`
if !strings.Contains(got, want) {
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())
}
}

func TestTemplate_RenderWithTableRowWrapper(t *testing.T) {
jw, rq := newCoreRequest(t)
// The native template action includes the structural td fragment without another
Expand Down Expand Up @@ -412,13 +497,43 @@ 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" {
t.Fatalf("OuterHTMLTag = %q, want %q", tpl.OuterHTMLTag, "div")
}
}

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.
Expand Down
10 changes: 7 additions & 3 deletions lib/ui/template_state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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)
}
Expand Down
Loading