fix: close #222 — track the Elements Register and RadioGroup create - #223
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #222.
The gap
RequestWriter.Registerand the lazy radio/label creation behindRequestWriter.RadioGroupcalledRequest.NewElementdirectly, so they were the only widget helpers aui.Templatedid not track (#221 fixed everyNewUI-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:{{$.Register .Updater}}whose Jid is discarded or printed as textRadioElement.Labelwithout itsRadiofor=attribute and never renderedJawsUpdatediscards the output, so the markup is never deliveredStale Elements also keep answering tag lookups, so
jw.Dirty(updater)fans out to Elements whose ids are absent from the DOM, where the client throwsjaws: element not found.The fix
Both helpers now report their Elements to the writer's owner, so the
Templatethat rendered them reclaims them like any other nested UI.Reporting moved to creation time (hook renamed
elementRendered→elementCreated). That is what covers the unrendered radio: the Element must exist before the render because its Jid supplies the group'sname=and the label'sfor=, and it may never render at all. The cost is that an owner's set can hold an Element whose render failed and whichNewUIalready unregistered — free, becauseRequest.DeleteElementsskips elements it finds unregistered and every rollback path deletes the whole set at once.The hook loses its
errorreturn. Its sole implementation cannot fail, and neither new call site could propagate one:Registerreturns ajid.JidandRadio/Labelreturntemplate.HTML, so both would have had to swallow it or route it throughMustLog, 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:NewUIrenders, andElement.JawsRenderappends a debug comment whenJaws.Debugis 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 callsJawsRender.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 thename=/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 holdscontentsbecause 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
RequestWriterthatRadioGroupwas called on — the template whose body called it, not the wrapper the markup lands in. Those differ only whenRadioElementvalues 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 sinceRadioElementallows Radio and Label at most one render each.TestRadioGroup_OwnedByTheTemplateThatCalledItpins 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 — becomesTestTemplate_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, andTestRequestWriter_NewUIReportsElementBeforeRendering(replacing the hook-returns-error test, whose path no longer exists).Both directions were checked rather than assumed:
trackElement's body emptied in a throwaway worktree, all 16 ownership tests fail, including every new one.TestRadioGroup_OwnedByTheTemplateThatCalledItfails 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.
BenchmarkTemplateUpdateOwnedCleanupstays as the guard on the update path.Gate run locally in
build.ymlorder:go generate,go vet,gofmt -l,staticcheck,golangci-lint(0 issues),gosec, both test legs withJAWS_REQUIRE_NODE=1, coverage (lib/uistill 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 ./...andgo test -c ./lib/bind/.