diff --git a/.agents/skills/jaws/SKILL.md b/.agents/skills/jaws/SKILL.md
index 1403ce09..06f680ed 100644
--- a/.agents/skills/jaws/SKILL.md
+++ b/.agents/skills/jaws/SKILL.md
@@ -158,16 +158,13 @@ For clickable content rendering:
- `ui.Template.JawsUpdate` re-renders the template data into the generated wrapper.
- `ui.NewTemplate` returns a `*ui.Template`, which tracks the Elements its execution
creates and therefore backs one live Element; construct a fresh one per render
- (`$.Template` already does). A successful update unregisters the Elements the
- previous execution created through `rw.NewUI` — the path every `RequestWriter`
- widget helper except `$.Register` and `$.RadioGroup` takes — since `SetInner`
- replaces the DOM holding them.
-- Those two helpers create Elements with `Request.NewElement`, so no template owns
- them and their cleanup falls to the browser, which reports the ids it removed from
- the DOM as the wrapper's new content is applied. An Element whose id never reaches
- the DOM stays registered until the request ends (a discarded `$.Register` Jid, or an
- execution that failed before delivering its markup), so prefer the `rw.NewUI`-backed
- helpers inside a template that updates.
+ (`$.Template` already does). A successful update unregisters every Element the
+ previous execution created through the writer it was given — the widget helpers,
+ `$.Register`, `$.RadioGroup` and nested `$.Template` alike — since `SetInner` replaces
+ the DOM holding them. Ownership is recorded at creation, so an Element that never
+ rendered is reclaimed as well.
+- Call `$.RadioGroup` from the template that renders the group: its Elements belong to
+ the template whose body called it, not to the wrapper their markup lands in.
- HTML getter paths must not mutate domain state, but they may call element update methods (`SetClass`, `RemoveClass`, `SetAttr`, `RemoveAttr`, etc.) on the passed-in `*Element` to co-ordinate wrapper class/attribute changes with the inner-HTML refresh. No custom `JawsUpdate` is needed for that case — the queued wrapper updates flush alongside the `SetInner` from `HTMLInner.JawsUpdate`.
- Use a custom `JawsUpdate` only when the widget's update logic diverges from "render the getter again" — e.g. to compare against a stored last-value and skip the update (as the input widgets do).
- `Element.SetAttr/RemoveAttr/SetClass/RemoveClass/SetInner/SetValue/Append/Order/Remove/Replace` are update-time operations; call them only from render/update processing.
diff --git a/lib/ui/README.md b/lib/ui/README.md
index 90ae9a3d..f080c383 100644
--- a/lib/ui/README.md
+++ b/lib/ui/README.md
@@ -29,26 +29,22 @@ already happened; it does not roll back partial output, queued messages, or
application side effects. The tracked elements the failed execution registered are
unregistered, since nothing will update them.
-A template owns the elements created through `rw.NewUI(...)`, the path taken by
-every `RequestWriter` widget helper except the two below — `{{$.Span ...}}`,
-`{{$.Button ...}}`, a nested `{{$.Template ...}}`, and so on. A successful update
-unregisters the ones the previous render left behind, along with the DOM that
-`SetInner` replaces. On updates that `SetInner` is queued only after a complete
-successful render, so a failed update leaves the browser DOM unchanged — and with
-it the previous render's elements — while earlier server-side side effects from
-that attempted render may remain. Treat template execution errors as application
-bugs: validate data before rendering and keep template actions infallible once they
-start emitting output or nested UI.
-
-`{{$.Register ...}}` and `{{$.RadioGroup ...}}` are the exceptions: they create
-elements through `Request.NewElement` directly rather than `rw.NewUI`, so the
-template neither owns nor unregisters them. Their cleanup falls to the browser,
-which reports the JaWS ids it removed from the DOM as the wrapper's new content is
-applied, and the request unregisters those elements. An element whose id never
-reaches the DOM has nothing to report it and stays registered until the request
-ends — one from an execution that failed before its markup was delivered, or a
-`$.Register` whose returned Jid the template discards. Prefer the `rw.NewUI`-backed
-helpers inside a template that updates.
+A template owns every element created through the `RequestWriter` it is given —
+`{{$.Span ...}}`, `{{$.Button ...}}`, `{{$.Register ...}}`, `{{$.RadioGroup ...}}`,
+a nested `{{$.Template ...}}`, and so on. A successful update unregisters the ones
+the previous render left behind, along with the DOM that `SetInner` replaces.
+Ownership is recorded when an element is created rather than after it renders, so
+an element that never reached the browser is reclaimed too. On updates that
+`SetInner` is queued only after a complete successful render, so a failed update
+leaves the browser DOM unchanged — and with it the previous render's elements —
+while earlier server-side side effects from that attempted render may remain. Treat
+template execution errors as application bugs: validate data before rendering and
+keep template actions infallible once they start emitting output or nested UI.
+
+`$.RadioGroup` has one attribution condition: its radio and label elements belong to
+the template whose body called it, not to the wrapper their markup lands in. Call it
+from the template that renders the group; see `RequestWriter.RadioGroup` for what
+happens when the two differ.
You can also use explicit constructors through:
diff --git a/lib/ui/radiogroup.go b/lib/ui/radiogroup.go
index b2ed0e9e..969d8868 100644
--- a/lib/ui/radiogroup.go
+++ b/lib/ui/radiogroup.go
@@ -15,8 +15,10 @@ import (
// renders register no elements on the [jaws.Request]. Call each of Radio and
// Label at most once. Render Label only when Radio is also rendered: Label emits a
// for="..." referencing the radio's id, so a Label without its Radio points at an
-// input that is absent from the document (and leaves an unrendered radio Element
-// registered on the Request for the request's lifetime).
+// input that is absent from the document. The radio Element it created is still
+// unregistered by the [Template] that owns it (see [RequestWriter.RadioGroup]) when
+// that template next replaces its content. With no template owner it has no DOM node
+// for a removal to report either, so it stays registered until the [jaws.Request] ends.
type RadioElement struct {
st *radioState
}
@@ -44,7 +46,14 @@ type radioGroupState struct {
// ordered before the label's regardless of which is rendered first.
func (st *radioState) radioElem() *jaws.Element {
if st.radio == nil {
+ // Create and report the Element rather than going through
+ // RequestWriter.NewUI: Radio and Label return their HTML for the template to
+ // place instead of writing it to the writer, and the Element has to exist
+ // before that render because its Jid supplies the group's name= and the
+ // label's for=. Reporting it here, at creation, also means a radio that is
+ // never rendered (a Label without its Radio) is still owned and reclaimed.
st.radio = st.rw.Request.NewElement(NewRadio(st.nb))
+ st.rw.trackElement(st.radio)
if st.group.nameAttr == "" {
st.group.nameAttr = `name="` + st.radio.Jid().String() + `"`
}
@@ -81,7 +90,9 @@ func (re RadioElement) Radio(params ...any) template.HTML {
func (re RadioElement) Label(params ...any) template.HTML {
radio := re.st.radioElem()
if re.st.label == nil {
+ // Created and reported like the radio Element; see radioElem.
re.st.label = re.st.rw.Request.NewElement(NewLabel(re.st.nb))
+ re.st.rw.trackElement(re.st.label)
}
var sb strings.Builder
forAttr := string(radio.Jid().AppendQuote([]byte("for=")))
@@ -97,13 +108,16 @@ func (re RadioElement) Label(params ...any) template.HTML {
// rendered radio in the group shares a name derived from the first created
// radio Element's request-scoped [jaws.Jid].
//
-// The radio and label Elements are created through [jaws.Request.NewElement] rather
-// than [RequestWriter.NewUI], so a surrounding [Template] neither owns nor unregisters
-// them when the template re-renders. Cleanup falls to the browser, which reports the
-// JaWS ids it removed from the DOM as the surrounding wrapper's new content is applied;
-// a rendered radio or label carries its own id, so it is reported. One that never
-// reached the DOM stays registered until the [jaws.Request] ends, including the
-// unrendered radio Element a [RadioElement.Label] without its Radio leaves behind.
+// The radio and label Elements belong to the [Template] whose body called RadioGroup,
+// which unregisters them when it next replaces its content. Ownership follows that call
+// site rather than the wrapper the markup lands in, so passing [RadioElement] values
+// into a nested wrapped template through its dot leaves them owned by the outer
+// template: an update of the inner wrapper alone replaces their DOM without their owner
+// reclaiming them, leaving that to the browser's removal acknowledgement for the ids
+// that reached the DOM and to the outer template's next update for any that did not.
+// Re-rendering them in the inner template is not an alternative, since [RadioElement]
+// allows Radio and Label at most one render each. Call RadioGroup from the template
+// that renders the group to avoid the condition entirely.
func (rw RequestWriter) RadioGroup(nba *named.BoolArray) (rel []RadioElement) {
group := &radioGroupState{}
nba.ReadLocked(func(nbl []*named.Bool) {
diff --git a/lib/ui/register.go b/lib/ui/register.go
index a6ece9f8..1689d53e 100644
--- a/lib/ui/register.go
+++ b/lib/ui/register.go
@@ -33,13 +33,14 @@ func (u Register) JawsRender(elem *jaws.Element, w io.Writer, params []any) erro
// The updater's [jaws.Updater.JawsUpdate] method will be called immediately to
// ensure the initial rendering is correct.
//
-// Register creates its Element through [jaws.Request.NewElement] rather than
-// [RequestWriter.NewUI], so a surrounding [Template] neither owns nor unregisters it
-// when the template re-renders. Cleanup falls to the browser, which reports the JaWS
-// ids it removed from the DOM as the surrounding wrapper's new content is applied. Use
-// the returned [jid.Jid] as an element id for that to work: an Element whose id never
-// reaches the DOM stays registered until the [jaws.Request] ends, and every re-render
-// of the template adds another.
+// A surrounding [Template] owns the Element and unregisters it when the template next
+// replaces its content, so repeated updates do not accumulate registrations. With no
+// template owner — rendered through a [RequestWriter] a caller built itself — cleanup
+// falls to the ordinary DOM-removal handling: the browser reports the JaWS ids it
+// removes when an ancestor's content is replaced, and [jaws.Element.Remove] unregisters
+// a managed child outright. Only an Element no removal ever reports, such as one whose
+// returned [jid.Jid] never becomes an element id, necessarily stays registered until the
+// [jaws.Request] ends.
//
// Register does not call [jaws.Renderer.JawsRender]. The updater must therefore
// be ready for JawsUpdate and event handling without render-time initialization.
@@ -51,7 +52,13 @@ func (u Register) JawsRender(elem *jaws.Element, w io.Writer, params []any) erro
//
//
...
func (rw RequestWriter) Register(updater jaws.Updater, params ...any) jid.Jid {
+ // Create and report the Element rather than going through RequestWriter.NewUI, so
+ // a surrounding Template owns it without anything being rendered: NewUI calls
+ // JawsRender, which appends a debug comment when Jaws.Debug is set, and the
+ // documented usage puts the returned Jid inside an attribute
+ // (), where that comment would corrupt the markup.
elem := rw.NewElement(Register{Updater: updater})
+ rw.trackElement(elem)
elem.Tag(updater)
// The wrapping Register element's UI is not the updater, so events reach the
// updater only through the element's handler list, not the elem.UI() fallback.
diff --git a/lib/ui/requestwriter.go b/lib/ui/requestwriter.go
index e27d491c..a15bee1a 100644
--- a/lib/ui/requestwriter.go
+++ b/lib/ui/requestwriter.go
@@ -11,15 +11,23 @@ import (
type RequestWriter struct {
*jaws.Request
io.Writer
- // elementRendered, when non-nil, is called by NewUI with each Element it
- // successfully rendered, letting the widget that owns this writer track the
- // Elements created through it. A returned error fails the NewUI call.
+ // elementCreated, when non-nil, is called with every Element created through
+ // this writer, letting the widget that owns the writer track them. It is called
+ // as soon as the Element exists, before it renders and whether or not it ever
+ // does, so an implementation must not assume rendered state.
//
// It is deliberately unexported: RequestWriter is embedded in [With], so an
// exported func field would be callable from any template as
- // {{call $.ElementRendered $.Element}}, letting a template make an Element own
+ // {{call $.ElementCreated $.Element}}, letting a template make an Element own
// itself and have its own wrapper unregistered on the next update.
- elementRendered func(elem *jaws.Element) (err error)
+ elementCreated func(elem *jaws.Element)
+}
+
+// trackElement reports a newly created Element to the writer's owner, if any.
+func (rw RequestWriter) trackElement(elem *jaws.Element) {
+ if rw.elementCreated != nil {
+ rw.elementCreated(elem)
+ }
}
// NewUI creates an element for ui and renders it to the underlying writer.
@@ -28,12 +36,12 @@ type RequestWriter struct {
// requirements documented by [jaws.UI].
func (rw RequestWriter) NewUI(ui jaws.UI, params ...any) (err error) {
elem := rw.NewElement(ui)
- if err = elem.JawsRender(rw, params); err == nil {
- if rw.elementRendered != nil {
- err = rw.elementRendered(elem)
- }
- }
- if err != nil {
+ // Report the Element before rendering it, so the owner's set is complete even for
+ // one that fails. That set may then hold an Element already unregistered below,
+ // which costs nothing: Request.DeleteElements skips elements it finds
+ // unregistered, and every rollback path deletes the whole set at once.
+ rw.trackElement(elem)
+ if err = elem.JawsRender(rw, params); err != nil {
// Unregister anything the failed Element already owns along with it, so no
// widget can strand a subtree by not rolling back itself.
deleteOwnedElements(rw.Request, []*jaws.Element{elem})
diff --git a/lib/ui/template.go b/lib/ui/template.go
index b57eb388..8a1d08fb 100644
--- a/lib/ui/template.go
+++ b/lib/ui/template.go
@@ -28,20 +28,19 @@ import (
// helper. The referenced template must be a partial template, not a full HTML
// document.
//
-// The Elements a template creates through [RequestWriter.NewUI] — the path taken by
-// every RequestWriter widget helper except [RequestWriter.Register] and
-// [RequestWriter.RadioGroup], including a nested [RequestWriter.Template] — belong to
-// the Template that rendered them: when [Template.JawsUpdate] replaces the wrapper's
-// content, those Elements are unregistered along with the DOM that held them, and a
-// nested widget's own Elements go with it.
+// Every Element a template creates through the [RequestWriter] it is given belongs to
+// the Template that rendered it — the widget helpers, [RequestWriter.Register],
+// [RequestWriter.RadioGroup] and a nested [RequestWriter.Template] alike. When
+// [Template.JawsUpdate] replaces the wrapper's content, those Elements are
+// unregistered along with the DOM that held them, and a nested widget's own Elements
+// go with it.
//
-// Those two helpers create their Elements through [jaws.Request.NewElement] instead,
-// so a Template neither tracks nor unregisters them. Their cleanup is left to the
-// browser, which reports the JaWS ids it removed from the DOM as the wrapper's new
-// content is applied, and the [jaws.Request] unregisters those Elements. An Element
-// whose id never reaches the DOM has nothing to report it and stays registered until
-// the Request ends: one from an execution that failed before its markup was
-// delivered, or from a Register call whose returned Jid the template discards.
+// Ownership is recorded when an Element is created rather than after it renders, so
+// one that never reaches the browser is reclaimed too: an Element whose render failed,
+// or a radio Element left unrendered by a [RadioElement.Label] without its
+// [RadioElement.Radio]. See [RequestWriter.RadioGroup] for the one attribution
+// condition, which applies when a group's markup ends up in a different wrapper than
+// the RadioGroup call.
//
// Template execution is best-effort rather than transactional. Template actions
// and nested JaWS helpers run as the template executes, so an execution error
@@ -81,12 +80,12 @@ func (tmpl *Template) String() string {
}
// ownElement records child as created while tmpl's template executed. It is the
-// [RequestWriter] element-rendered hook installed by execute.
-func (tmpl *Template) ownElement(child *jaws.Element) (err error) {
+// [RequestWriter] element-created hook installed by execute, so it is called as soon
+// as the Element exists and makes no assumption about whether it rendered.
+func (tmpl *Template) ownElement(child *jaws.Element) {
tmpl.mu.Lock()
tmpl.owned = append(tmpl.owned, child)
tmpl.mu.Unlock()
- return
}
// takeOwnedElements returns the Elements created by the most recent execution and
@@ -137,7 +136,7 @@ func (tmpl *Template) execute(elem *jaws.Element, w io.Writer, lookedUp *templat
// races on the shared io.Writer, and on the render as a whole.
err = lookedUp.Execute(w, With{
Element: elem,
- RequestWriter: RequestWriter{Request: elem.Request, Writer: w, elementRendered: tmpl.ownElement},
+ RequestWriter: RequestWriter{Request: elem.Request, Writer: w, elementCreated: tmpl.ownElement},
Dot: tmpl.Dot,
Auth: tmpl.auth(elem),
})
diff --git a/lib/ui/template_owned_test.go b/lib/ui/template_owned_test.go
index 583796a3..3ac81365 100644
--- a/lib/ui/template_owned_test.go
+++ b/lib/ui/template_owned_test.go
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"html/template"
+ "slices"
"strings"
"sync"
"testing"
@@ -28,6 +29,12 @@ const ownedTestTemplates = `
{{define "owned-container"}}{{$.RequestWriter.Container "div" $.Dot.Container}}{{end}}
{{define "owned-register"}}
{{end}}
{{define "owned-radiogroup"}}{{range $.RequestWriter.RadioGroup $.Dot.Radios}}{{.Radio}}{{.Label}}{{end}}{{end}}
+{{define "owned-register-discarded"}}registered as text: {{$.RequestWriter.Register $.Dot}}{{end}}
+{{define "owned-labelonly"}}{{range $.RequestWriter.RadioGroup $.Dot.Radios}}{{.Label}}{{end}}{{end}}
+{{define "owned-register-failafter"}}
{{$.Dot.Check}}{{end}}
+{{define "owned-radiogroup-failafter"}}{{range $.RequestWriter.RadioGroup $.Dot.Radios}}{{.Radio}}{{.Label}}{{end}}{{$.Dot.Check}}{{end}}
+{{define "owned-radio-outer"}}{{$.RequestWriter.Template "div" "owned-radio-inner" ($.Dot.Box $.RequestWriter)}}{{end}}
+{{define "owned-radio-inner"}}{{range $.Dot.Elements}}{{.Radio}}{{.Label}}{{end}}{{end}}
`
var errOwnedDotCheck = errors.New("owned dot check failed")
@@ -40,6 +47,7 @@ type ownedDot struct {
container *testContainer
names []string
radios *named.BoolArray
+ box *ownedRadioBox
}
func (d *ownedDot) Check() (string, error) {
@@ -60,6 +68,33 @@ func (d *ownedDot) Names() []string { return d.names }
func (d *ownedDot) Radios() *named.BoolArray { return d.radios }
+// Box builds the radio group with the passed-in writer — the outer template's — and
+// returns the box carrying the RadioElement values to the nested template, whose dot it
+// becomes. It is a pointer so it is usable as a tag.
+func (d *ownedDot) Box(rw RequestWriter) *ownedRadioBox {
+ d.box.rel = rw.RadioGroup(d.radios)
+ return d.box
+}
+
+// ownedRadioBox carries RadioElement values across a template boundary, so a test can
+// render them in a nested template while the outer template owns their Elements.
+type ownedRadioBox struct {
+ rel []RadioElement
+ show bool
+ execs int // executions of the nested template that read Elements
+}
+
+// Elements returns the group to render, or nothing once show is cleared. It counts
+// calls so a test can tell an update that ran and rendered nothing from one that never
+// executed.
+func (b *ownedRadioBox) Elements() []RadioElement {
+ b.execs++
+ if !b.show {
+ return nil
+ }
+ return b.rel
+}
+
// JawsUpdate makes ownedDot usable as the $.Register updater. Register tags its
// Element with the updater, so the dot still tags the whole subtree.
func (d *ownedDot) JawsUpdate(*jaws.Element) {}
@@ -324,30 +359,35 @@ func TestTemplate_RenderClosingWriteFailureDeletesOwnedElements(t *testing.T) {
}
}
-// TestRequestWriter_NewUIElementRenderedFailureDeletesOwnedElements checks that a
-// failing element-rendered hook unregisters the Element and everything it owns.
-func TestRequestWriter_NewUIElementRenderedFailureDeletesOwnedElements(t *testing.T) {
+// TestRequestWriter_NewUIReportsElementBeforeRendering checks that NewUI reports the
+// Element it created even when the render then fails, and unregisters it regardless.
+// Reporting at creation is what lets an owner reclaim an Element that never rendered.
+func TestRequestWriter_NewUIReportsElementBeforeRendering(t *testing.T) {
_, rq := newOwnedRequest(t)
- hookErr := errors.New("hook refused the element")
- var seen int
+ var seen []*jaws.Element
var sb strings.Builder
rw := RequestWriter{
Request: rq,
Writer: &sb,
- elementRendered: func(elem *jaws.Element) error {
- seen++
- return hookErr
+ elementCreated: func(elem *jaws.Element) {
+ seen = append(seen, elem)
},
}
- if err := rw.Template("div", "owned-parent", &ownedDot{}); !errors.Is(err, hookErr) {
- t.Fatalf("NewUI error = %v, want %v", err, hookErr)
+ // The template renders nested UI and then fails, so this writer creates one
+ // Element (the wrapper) and the nested writer inside it creates its own.
+ dot := &ownedDot{fail: errOwnedDotCheck}
+ if err := rw.Template("div", "owned-failafter", dot); !errors.Is(err, errOwnedDotCheck) {
+ t.Fatalf("render error = %v, want %v", err, errOwnedDotCheck)
+ }
+ if len(seen) != 1 {
+ t.Fatalf("hook calls = %d, want 1 (only Elements created directly through this writer)", len(seen))
}
- if seen != 1 {
- t.Fatalf("hook calls = %d, want 1 (only Elements created through this writer)", seen)
+ if !seen[0].Deleted() {
+ t.Error("the reported Element was not unregistered after its render failed")
}
if got := countRegistered(t, rq); got != 0 {
- t.Fatalf("registered elements after hook failure = %d, want 0", got)
+ t.Fatalf("registered elements after failed render = %d, want 0", got)
}
}
@@ -459,43 +499,43 @@ func TestPageTemplate_RenderFailureDeletesOwnedElements(t *testing.T) {
}
}
-// TestTemplate_UpdateDoesNotTrackRegisterOrRadioGroup pins the documented
-// exclusion: Register and RadioGroup create their Elements through
-// Request.NewElement rather than RequestWriter.NewUI, so a Template does not own
-// them and each update registers another set.
+// TestTemplate_UpdateTracksRegisterAndRadioGroup covers the two helpers that create
+// their Elements through Request.NewElement rather than RequestWriter.NewUI: they
+// report them to the writer's owner, so the counts stay flat across updates with no
+// client attached to acknowledge DOM removals.
//
-// The growth measured here is what the server does on its own. In a live session the
-// browser reports the ids it removes as the wrapper's new content is applied and the
-// Request unregisters those Elements; there is no client here to send that
-// acknowledgement, which is also the state of an Element whose id never reaches the
-// DOM. Should either helper start reporting through the element-rendered hook, these
-// counts become constant — update this test along with the Template, Register,
-// RadioGroup, README and skill docs stating the exclusion.
-func TestTemplate_UpdateDoesNotTrackRegisterOrRadioGroup(t *testing.T) {
- radios := named.NewBoolArray(false)
- radios.Add("1", "one")
+// The label-only case is why ownership is recorded at creation: RadioElement.Label
+// creates the radio Element for its for= attribute without ever rendering it, so an
+// after-render notification would miss it and it would accumulate.
+func TestTemplate_UpdateTracksRegisterAndRadioGroup(t *testing.T) {
+ newRadios := func() *named.BoolArray {
+ nba := named.NewBoolArray(false)
+ nba.Add("1", "one")
+ return nba
+ }
for _, tt := range []struct {
name string
template string
dot *ownedDot
- perRound int // Elements the helper adds per execution
+ perRound int // Elements the helper creates per execution
}{
{"register", "owned-register", &ownedDot{}, 1},
- {"radiogroup", "owned-radiogroup", &ownedDot{radios: radios}, 2}, // input and label
+ {"register discarded jid", "owned-register-discarded", &ownedDot{}, 1},
+ {"radiogroup", "owned-radiogroup", &ownedDot{radios: newRadios()}, 2}, // input and label
+ {"radiogroup label only", "owned-labelonly", &ownedDot{radios: newRadios()}, 2}, // unrendered radio, label
} {
t.Run(tt.name, func(t *testing.T) {
_, rq := newOwnedRequest(t)
tmpl := NewTemplate("div", tt.template, tt.dot)
elem := renderOwned(t, rq, tmpl)
- want := 1 + tt.perRound // the wrapper, plus the first execution's
+ want := 1 + tt.perRound // the wrapper, plus this execution's
if got := countRegistered(t, rq); got != want {
t.Fatalf("registered elements after render = %d, want %d", got, want)
}
for round := 1; round <= 3; round++ {
tmpl.JawsUpdate(elem)
- want += tt.perRound
if got := countRegistered(t, rq); got != want {
t.Fatalf("registered elements after %d update(s) = %d, want %d", round, got, want)
}
@@ -504,6 +544,125 @@ func TestTemplate_UpdateDoesNotTrackRegisterOrRadioGroup(t *testing.T) {
}
}
+// TestTemplate_UpdateFailureKeepsRegisterAndRadioGroupGeneration covers the remaining
+// leak mode: execution failing after either helper has already created its Elements.
+// The failed execution's output is discarded, so its Elements must go and the previous
+// generation must stay live to match the unchanged DOM.
+func TestTemplate_UpdateFailureKeepsRegisterAndRadioGroupGeneration(t *testing.T) {
+ newRadios := func() *named.BoolArray {
+ nba := named.NewBoolArray(false)
+ nba.Add("1", "one")
+ return nba
+ }
+
+ for _, tt := range []struct {
+ name string
+ template string
+ dot *ownedDot
+ perRound int
+ }{
+ {"register", "owned-register-failafter", &ownedDot{}, 1},
+ {"radiogroup", "owned-radiogroup-failafter", &ownedDot{radios: newRadios()}, 2},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ jw, rq := newOwnedRequest(t)
+ logger := new(templateLogger)
+ jw.Logger = logger
+
+ tmpl := NewTemplate("div", tt.template, tt.dot)
+ elem := renderOwned(t, rq, tmpl)
+ want := 1 + tt.perRound
+ if got := countRegistered(t, rq); got != want {
+ t.Fatalf("registered elements after render = %d, want %d", got, want)
+ }
+ first := registeredJids(t, rq)
+
+ tt.dot.setFail(errOwnedDotCheck)
+ tmpl.JawsUpdate(elem)
+ if len(logger.errors) != 1 || !errors.Is(logger.errors[0], errOwnedDotCheck) {
+ t.Fatalf("logged errors = %v, want one %v", logger.errors, errOwnedDotCheck)
+ }
+ if got := registeredJids(t, rq); !slices.Equal(got, first) {
+ t.Fatalf("registered jids after failed update = %v, want the previous generation %v", got, first)
+ }
+
+ // The previous generation must still be owned, so the next success reclaims
+ // it rather than leaking it.
+ tt.dot.setFail(nil)
+ tmpl.JawsUpdate(elem)
+ if got := countRegistered(t, rq); got != want {
+ t.Fatalf("registered elements after recovery = %d, want %d", got, want)
+ }
+ if got := registeredJids(t, rq); slices.Equal(got, first) {
+ t.Fatal("the recovering update did not replace the previous generation")
+ }
+ })
+ }
+}
+
+// TestRadioGroup_OwnedByTheTemplateThatCalledIt pins where ownership is attributed
+// when a group's markup lands in a different wrapper than the RadioGroup call: the
+// outer template calls $.RadioGroup and passes the RadioElement values to a nested
+// wrapped template through its dot.
+//
+// Updating the outer template alone would not distinguish the two candidates, since
+// the cleanup walk recurses into the nested Template's own owned set either way. The
+// discriminating step is updating the nested Element on its own: with the radios no
+// longer rendered and no client to acknowledge the DOM removal, they must still be
+// registered, which is what fails if the nested template owned them.
+func TestRadioGroup_OwnedByTheTemplateThatCalledIt(t *testing.T) {
+ _, rq := newOwnedRequest(t)
+
+ radios := named.NewBoolArray(false)
+ radios.Add("1", "one")
+ box := &ownedRadioBox{show: true}
+ dot := &ownedDot{radios: radios, box: box}
+ outer := NewTemplate("div", "owned-radio-outer", dot)
+ outerElem := renderOwned(t, rq, outer)
+
+ // outer wrapper, nested wrapper, radio, label
+ const want = 4
+ if got := countRegistered(t, rq); got != want {
+ t.Fatalf("registered elements after render = %d, want %d", got, want)
+ }
+ inner := rq.GetElements(box)
+ if len(inner) != 1 {
+ t.Fatalf("nested wrapper elements = %d, want 1", len(inner))
+ }
+
+ // The nested template updates on its own and stops rendering the group. Counting
+ // its executions keeps the assertion below from passing vacuously: the radios must
+ // survive an update that ran and dropped them, not one that never happened.
+ box.show = false
+ execsBefore := box.execs
+ inner[0].JawsUpdate()
+ if box.execs != execsBefore+1 {
+ t.Fatalf("nested template executions = %d, want %d: the nested update did not run",
+ box.execs, execsBefore+1)
+ }
+ if got := countRegistered(t, rq); got != want {
+ t.Fatalf("registered elements after the nested update = %d, want %d: the radio and label "+
+ "belong to the template that called RadioGroup, so the nested update must not reclaim them", got, want)
+ }
+
+ // The owner reclaims them, along with the nested wrapper it also owns.
+ outer.JawsUpdate(outerElem)
+ if got := countRegistered(t, rq); got != 2 {
+ t.Fatalf("registered elements after the outer update = %d, want 2 (both wrappers, no group)", got)
+ }
+}
+
+// registeredJids returns the Jids still registered in rq, in ascending order.
+func registeredJids(t *testing.T, rq *jaws.Request) (jids []jaws.Jid) {
+ t.Helper()
+ for jid := jaws.Jid(1); jid <= maxProbedJid; jid++ {
+ if rq.GetElementByJid(jid) != nil {
+ jids = append(jids, jid)
+ }
+ }
+ return
+}
+
// TestTemplate_UpdateReclaimsManyTaggedDescendants exercises the batched
// unregister with a generation large enough to span several tag entries.
func TestTemplate_UpdateReclaimsManyTaggedDescendants(t *testing.T) {