Summary
Re-rendering a wrapped ui.Template that invokes an unwrapped nested template registers a fresh nested Element on every update. The old nested Elements are never deleted even though the parent's SetInner replaces their output.
Class / severity
Resource lifecycle — High. Ordinary repeated updates cause unbounded growth of the Request's Element registry and tag routing state.
Contract and valid usage
ui.Template explicitly allows OuterHTMLTag to be empty and says nested JaWS UI rendered by an unwrapped template can update through its own Elements. RequestWriter.Template documents the same unwrapped mode. Calling that helper from a Go template is therefore supported usage.
Reproduction
package ui_test
import (
"html/template"
"testing"
"time"
"github.com/linkdata/jaws"
"github.com/linkdata/jaws/jawstest"
"github.com/linkdata/jaws/lib/tag"
"github.com/linkdata/jaws/lib/ui"
"github.com/linkdata/jaws/lib/what"
"github.com/linkdata/jaws/lib/wire"
)
func TestNestedUnwrappedTemplateDoesNotLeakOnParentUpdate(t *testing.T) {
jw, err := jaws.New()
if err != nil {
t.Fatal(err)
}
t.Cleanup(jw.Close)
go jw.Serve()
lookuper := template.Must(template.New("templates").Parse(`
{{define "parent"}}{{$.RequestWriter.Template "" "child" $.Dot}}{{end}}
{{define "child"}}child{{end}}
`))
if err := jw.AddTemplateLookuper(lookuper); err != nil {
t.Fatal(err)
}
tr := jawstest.NewTestRequest(jw, nil)
t.Cleanup(func() {
tr.Close()
<-tr.DoneCh
})
<-tr.ReadyCh
marker := tag.Tag("nested")
rw := ui.RequestWriter{Request: tr.Request, Writer: tr.Recorder}
if err := rw.NewUI(ui.NewTemplate("div", "parent", marker)); err != nil {
t.Fatal(err)
}
if got := len(tr.GetElements(marker)); got != 2 {
t.Fatalf("initial tagged elements = %d, want 2", got)
}
tr.BcastCh <- wire.Message{Dest: marker, What: what.Update}
select {
case <-tr.OutCh:
case <-time.After(time.Second):
t.Fatal("timeout waiting for parent update")
}
if got := len(tr.GetElements(marker)); got != 2 {
t.Fatalf("tagged elements after one update = %d, want 2", got)
}
}
The final count is 3, and it increases by one on every subsequent update.
Root cause
Template.JawsUpdate (lib/ui/template.go:142-149) executes the parent template into a new builder. The nested RequestWriter.Template reaches RequestWriter.NewUI, which unconditionally calls Request.NewElement (lib/ui/requestwriter.go:20-24). On success nothing records ownership of that nested Element or removes the previous one before SetInner replaces the DOM content.
An unwrapped nested template makes the leak especially direct: its registered Element has no generated DOM identity of its own, but remains in rq.elems and rq.tagMap for the Request lifetime.
Impact
Frequently updated pages accumulate Elements, tags, handlers, and referenced application state. Tag broadcasts increasingly visit stale Elements, increasing memory and update work until the Request ends.
Suggested fix
Track Elements created while executing a template as descendants of its wrapper and reconcile/delete the previous descendant set when SetInner replaces it. Ensure rollback also removes newly allocated descendants if execution fails.
Confidence
Confirmed on main at 6fd13a2.
Summary
Re-rendering a wrapped
ui.Templatethat invokes an unwrapped nested template registers a fresh nestedElementon every update. The old nested Elements are never deleted even though the parent'sSetInnerreplaces their output.Class / severity
Resource lifecycle — High. Ordinary repeated updates cause unbounded growth of the Request's Element registry and tag routing state.
Contract and valid usage
ui.Templateexplicitly allowsOuterHTMLTagto be empty and says nested JaWS UI rendered by an unwrapped template can update through its own Elements.RequestWriter.Templatedocuments the same unwrapped mode. Calling that helper from a Go template is therefore supported usage.Reproduction
The final count is 3, and it increases by one on every subsequent update.
Root cause
Template.JawsUpdate(lib/ui/template.go:142-149) executes the parent template into a new builder. The nestedRequestWriter.TemplatereachesRequestWriter.NewUI, which unconditionally callsRequest.NewElement(lib/ui/requestwriter.go:20-24). On success nothing records ownership of that nested Element or removes the previous one beforeSetInnerreplaces the DOM content.An unwrapped nested template makes the leak especially direct: its registered Element has no generated DOM identity of its own, but remains in
rq.elemsandrq.tagMapfor the Request lifetime.Impact
Frequently updated pages accumulate Elements, tags, handlers, and referenced application state. Tag broadcasts increasingly visit stale Elements, increasing memory and update work until the Request ends.
Suggested fix
Track Elements created while executing a template as descendants of its wrapper and reconcile/delete the previous descendant set when
SetInnerreplaces it. Ensure rollback also removes newly allocated descendants if execution fails.Confidence
Confirmed on
mainat6fd13a2.