Skip to content

fix: close #222 — track the Elements Register and RadioGroup create - #223

Merged
linkdata merged 2 commits into
mainfrom
fix/222-register-radiogroup-owned
Aug 3, 2026
Merged

fix: close #222 — track the Elements Register and RadioGroup create#223
linkdata merged 2 commits into
mainfrom
fix/222-register-radiogroup-owned

Conversation

@linkdata

@linkdata linkdata commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Closes #222.

The gap

RequestWriter.Register and the lazy radio/label creation behind RequestWriter.RadioGroup called Request.NewElement directly, so they were the only widget helpers a ui.Template did not track (#221 fixed every NewUI-backed helper). A template that updates registered a fresh set on every execution and unregistered none, leaving the browser's removal acknowledgement as the only cleanup — and that can name only ids which reached the DOM. Growth was unbounded for every shape where one does not:

shape why the id never reaches the DOM
{{$.Register .Updater}} whose Jid is discarded or printed as text never used as an element id
RadioElement.Label without its Radio the radio Element is created for the for= attribute and never rendered
execution failing after either helper ran JawsUpdate discards the output, so the markup is never delivered

Stale Elements also keep answering tag lookups, so jw.Dirty(updater) fans out to Elements whose ids are absent from the DOM, where the client throws jaws: element not found.

The fix

Both helpers now report their Elements to the writer's owner, so the Template that rendered them reclaims them like any other nested UI.

Reporting moved to creation time (hook renamed elementRenderedelementCreated). That is what covers the unrendered radio: the Element must exist before the render because its Jid supplies the group's name= and the label's for=, and it may never render at all. The cost is that an owner's set can hold an Element whose render failed and which NewUI already unregistered — free, because Request.DeleteElements skips elements it finds unregistered and every rollback path deletes the whole set at once.

The hook loses its error return. Its sole implementation cannot fail, and neither new call site could propagate one: Register returns a jid.Jid and Radio/Label return template.HTML, so both would have had to swallow it or route it through MustLog, which panics when no logger is configured.

Neither helper routes through NewUI, and the reason differs per helper, so each carries its own comment rather than one blanket claim:

  • Register: NewUI renders, and Element.JawsRender appends a debug comment when Jaws.Debug is set. The documented usage puts the returned Jid inside an attribute, <div id="{{$.Register .X}}">, where that comment would corrupt the markup — which is why Register documents that it never calls JawsRender.
  • Radio / Label: they return their HTML for the template to place instead of writing to the writer, and need the Element to exist before that render for the name=/for= attributes, created lazily on first use with one radio identity shared between them.

A group Element owning the radios, mirroring ContainerHelper, was considered and rejected: that helper holds contents because it reconciles children across updates (reuse, append, remove, order), while a RadioGroup's Elements are created once per execution and never reconciled.

Attribution condition

Ownership follows the RequestWriter that RadioGroup was called on — the template whose body called it, not the wrapper the markup lands in. Those differ only when RadioElement values cross a template boundary in the dot. RequestWriter.RadioGroup's doc states the condition, including that re-rendering them in the inner template is not a workaround since RadioElement allows Radio and Label at most one render each.

TestRadioGroup_OwnedByTheTemplateThatCalledIt pins it. An outer update cannot distinguish the two attributions, because the cleanup walk recurses into the nested Template's own owned set either way, so the test updates the nested wrapper alone with the group no longer rendered and asserts the radio and label are still registered. It also counts nested executions, so the assertion cannot pass vacuously on an update that never ran.

Testing

TestTemplate_UpdateDoesNotTrackRegisterOrRadioGroup — the characterization test that asserted the growth — becomes TestTemplate_UpdateTracksRegisterAndRadioGroup, extended to the discarded-Jid and label-only shapes. New: TestTemplate_UpdateFailureKeepsRegisterAndRadioGroupGeneration (execution failing after either helper ran: the failed generation goes, the previous stays live, the next success reclaims exactly it), TestRadioGroup_OwnedByTheTemplateThatCalledIt, and TestRequestWriter_NewUIReportsElementBeforeRendering (replacing the hook-returns-error test, whose path no longer exists).

Both directions were checked rather than assumed:

  • With trackElement's body emptied in a throwaway worktree, all 16 ownership tests fail, including every new one.
  • With the nested template made to own the group instead (same scenario, only the writer that builds it differs), TestRadioGroup_OwnedByTheTemplateThatCalledIt fails at exactly the middle assertion — so it pins attribution, not merely that ownership exists.

No new benchmark: this adds one nil check and one slice append per created Element and makes no performance claim. BenchmarkTemplateUpdateOwnedCleanup stays as the guard on the update path.

Gate run locally in build.yml order: go generate, go vet, gofmt -l, staticcheck, golangci-lint (0 issues), gosec, both test legs with JAWS_REQUIRE_NODE=1, coverage (lib/ui still 100.0%), go build -v, plus -tags debug -race. The 386 leg cannot execute on this arm64 host, so it was verified by compiling: GOARCH=386 CGO_ENABLED=0 go vet ./... and go test -c ./lib/bind/.

RequestWriter.Register and the lazy radio/label creation behind RadioGroup called
Request.NewElement directly, so they were the only widget helpers a ui.Template
did not track. A template that updates registered a fresh set on every execution
and unregistered none, leaving the browser's removal acknowledgement as the only
cleanup — and that can name only ids which reached the DOM. Growth was unbounded
for every shape where one does not: a $.Register whose returned Jid is discarded
or printed as text, a RadioElement.Label rendered without its Radio (which leaves
the radio Element created but never rendered), and an execution that fails before
its markup is delivered. Stale Elements also keep answering tag lookups, so
Dirty(updater) fans out to ids absent from the DOM, where the client throws.

Both helpers now report their Elements to the writer's owner, so the Template that
rendered them reclaims them like any other nested UI.

Reporting happens at creation rather than after a successful render, and the hook
is renamed elementCreated to say so. That is what covers the unrendered radio: the
Element exists because its Jid supplies the group's name= and the label's for=,
and it may never render. The cost is that an owner's set can hold an Element whose
render failed and which NewUI already unregistered, which is free —
Request.DeleteElements skips elements it finds unregistered and every rollback
deletes the whole set at once.

The hook loses its error return. Its sole implementation cannot fail, and neither
new call site could propagate one: Register returns a jid.Jid and Radio/Label
return template.HTML, so both would have had to swallow it or route it through
MustLog, which panics when no logger is configured.

Neither helper routes through NewUI, and the reason differs per helper, so each
carries its own comment. NewUI renders, and Element.JawsRender appends a debug
comment when Jaws.Debug is set; Register's documented usage puts the returned Jid
inside an attribute, where that comment would corrupt the markup. Radio and Label
instead return their HTML for the template to place, and need the Element before
that render for the name= and for= attributes.

Ownership follows the RequestWriter that RadioGroup was called on, which is the
template whose body called it rather than the wrapper the markup lands in. Those
differ only when RadioElement values cross a template boundary in the dot;
RadioGroup's doc states the condition, and a test pins it by updating the nested
wrapper alone and asserting the group survives, since an outer update cannot
distinguish the two attributions.

Docs updated where #221 recorded the exception: Template, Register, RadioGroup,
RadioElement, the lib/ui README and the tracked skill. The characterization test
that asserted the growth becomes TestTemplate_UpdateTracksRegisterAndRadioGroup,
now covering the discarded-Jid and label-only shapes, alongside new tests for
execution failing after either helper ran and for the attribution boundary.
… owner

Register and RadioElement said an Element with no template owner stays
registered until the Request ends. Losing the owner only removes one cleanup
path: the browser still reports the JaWS ids it removes when an ancestor's
content is replaced, and Element.Remove unregisters a managed child outright.

Say that cleanup falls to the ordinary DOM-removal handling in that case, and
that only an Element no removal ever reports — one whose Jid never becomes an
element id — necessarily lasts until request teardown. For the radio Element a
Label leaves behind, that condition is met by construction: it has no DOM node
for any removal to report, so the original claim holds there and now says why.
@linkdata
linkdata merged commit 9b02d46 into main Aug 3, 2026
7 checks passed
@linkdata
linkdata deleted the fix/222-register-radiogroup-owned branch August 3, 2026 09:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ui: Register and RadioGroup bypass RequestWriter.NewUI, so a Template does not track their Elements

1 participant