Skip to content

refactor!: simplify the tag/getter API and fix #217 - #227

Open
linkdata wants to merge 2 commits into
mainfrom
simplify-tag-getter-api
Open

refactor!: simplify the tag/getter API and fix #217#227
linkdata wants to merge 2 commits into
mainfrom
simplify-tag-getter-api

Conversation

@linkdata

@linkdata linkdata commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes #217. Closes #224.

Investigating both issues showed that most of the surface they touch exists only to support machinery nothing uses. Compatibility breaks are accepted; there are no out-of-repository widget implementations or TagGetter consumers to preserve.

Remove tag.Context

It was threaded through every JawsGetTag signature and all of expand's recursion, but no production implementation read it. Its only consumer was tag.MustTagExpand calling ctx.MustLog — an inversion that let lib/tag log through a *jaws.Request without importing jaws, at the cost of an unused parameter on every implementer.

  • TagGetter becomes JawsGetTag() any
  • TagExpand becomes TagExpand(tagValue any) ([]any, error)
  • lib/tag/context.go is deleted; the lib/tag import drops out of six files whose only use was tag.Context, so their [tag.X] doc links are now fully qualified

Request.Initial, Get, Set, Context, Log and MustLog all stay — they are public Request API in their own right, not just tag.Context plumbing.

Remove InitHandler / JawsInit

This is the mechanism #224 reported: it ran after tag resolution, so a getter that changed its tags there desynchronized registration from routing. It had no production implementation in this repo; its only uses were test fixtures exploiting it to make a render fail. Element.ApplyGetter therefore drops its error result:

func (elem *Element) ApplyGetter(getter any) (tagValue any, attrs []template.HTMLAttr)

The two fixtures that abused it are replaced by the render-error fixtures already present in package ui (testRenderErrorUI, failNthWrite), so the genuine NewUI cleanup and renderInner write-error coverage survives.

InitialHTMLAttrHandler is unaffected.

Move MustTagExpand to *jaws.Jaws

It existed only to pair TagExpand with Context.MustLog, so it belongs where MustLog lives:

func (jw *Jaws) MustTagExpand(tagValue any) (result []any)

Request.Dirty, Request.Tag, Request.GetElements, Jaws.Dirty and Jaws.Broadcast all route through it. ui.Template stays on tag.TagExpand so it can return expansion errors normally. lib/tag's internal tests cannot import jaws (tag → jaws → tag), so the logging/panic coverage moved to the root package and lib/bind uses a small local mustExpand helper rather than constructing a *Jaws to expand a value.

Fix #217

bind.HTMLGetterFunc and bind.StringGetterFunc retained the caller's variadic tags slice header, so reusing that slice retagged a live getter. They now shallow-clone the top-level slots. The doc comments state the boundary rather than overclaiming it: nested containers and reference-backed tag values are not copied and stay the caller's obligation. Mutation regressions sit next to each constructor.

Close #224 as a contract clarification

Element.ApplyGetter deliberately returns a candidate that widgets retain and re-expand, so the fix is to say what a getter owes. lib/tag/taggetter.go is now the authoritative statement:

  • JawsGetTag is the canonical public accessor and application code may call it directly; callers wanting flattened, validated keys use TagExpand.
  • ApplyGetter invokes it to obtain a tag candidate, then expands that candidate for registration; the expansion may invoke JawsGetTag again when the candidate is itself a TagGetter or contains one. Standard getter-backed widgets invoke ApplyGetter once during initial render, but there is no JawsGetTag call-count guarantee: TagExpand, dirtying, broadcasts and application code may make further calls.
  • Except for an explicitly documented initialization phase that returns nil, a TagGetter must be idempotent in tag identity. After its first non-nil result, every call must return a value that TagExpand expands to the same set of keys; previously returned containers must continue expanding to the key set they produced and must be treated as read-only. Fresh containers and equivalent representations are fine. Non-idempotent implementations are unsupported.
  • JaWS does not serialize JawsGetTag calls; a getter used concurrently must synchronize its state and safely publish returned containers.

ui.JsVar is documented as the one in-tree initialization case: nil before its first render initializes the dirty tag, and that nil is not a dirty target.

All fifteen production JawsGetTag implementations were audited against this. The two lib/bind getter-funcs were the only violations, which is exactly #217.

Doc comments corrected along the way

Jaws.Dirty's comment claimed three things that are now false: that TagGetters are called with a nil Request (there is no context), that Request.Dirty avoids that (the two are equivalent, and Request.Dirty is not even request-scoped — it delegates to Jaws-wide dirtying), and that a non-hashable tag panics — a path TagExpand makes unreachable by rejecting unusable keys before setDirty sees them. It now documents what a caller actually observes: with a Logger the expansion error is logged and the partial result is still applied; without one the call panics before anything is applied. Request.Dirty, Request.Tag, Request.GetElements and Jaws.Broadcast get the same treatment, and Request.TagExpanded is marked as the advanced API that adds keys without expanding or validating them.

Request.TagExpanded also checks deletion while holding the Request lock, making concurrent deletion and tag registration linearizable. A deterministic regression test covers the stale pre-lock check that could re-register a deleted Element.

.agents/skills/jaws/SKILL.md and lib/ui/README.md are updated to match.

Verification

gofmt, go generate, go build -v, go vet, staticcheck, golangci-lint and gosec are all clean. Tests pass under -race, under -tags debug -race, and in the plain production build (which exercises the release TagString path), all with JAWS_REQUIRE_NODE=1. lib/bind compiles and vets clean under GOARCH=386 CGO_ENABLED=0 (this host cannot exec 32-bit binaries). FuzzParseParams ran 30s clean after its recipe table was edited.

Coverage holds at the 99.6% baseline. Request.MustLog gained a direct test: it was previously covered incidentally through tag.MustTagExpand(rq, …), which no longer exists.

Investigating #224 and #217 showed that most of the surface they touch exists only
to support machinery nothing uses.

Remove tag.Context. It was threaded through every JawsGetTag signature and all of
expand's recursion, but no production implementation read it. Its only consumer was
tag.MustTagExpand calling ctx.MustLog — an inversion that let lib/tag log through a
*jaws.Request without importing jaws, at the cost of an unused parameter on every
implementer. TagGetter becomes JawsGetTag() any and TagExpand becomes
TagExpand(tagValue any) ([]any, error).

Remove InitHandler/JawsInit. This is the mechanism #224 reported: it ran after tag
resolution, so a getter that changed its tags there desynchronized registration from
routing. It had no production implementation; its only uses were test fixtures
exploiting it to fail a render. Element.ApplyGetter therefore drops its error result
and returns (tagValue, attrs).

Move MustTagExpand to *jaws.Jaws, where MustLog lives. Request.Dirty, Request.Tag,
Request.GetElements, Jaws.Dirty and Jaws.Broadcast all route through it; ui.Template
stays on tag.TagExpand so it can return expansion errors normally.

Fix #217: bind.HTMLGetterFunc and bind.StringGetterFunc retained the caller's
variadic tags slice header, so reusing that slice retagged a live getter. They now
shallow-clone the top-level slots, which is documented along with the boundary —
nested containers and reference-backed tag values stay the caller's obligation.

Close #224 by documenting the TagGetter contract in lib/tag/taggetter.go: JawsGetTag
is the canonical public accessor; ApplyGetter calls it exactly once per invocation
but there is no global call-count guarantee; and, except for a documented
initialization phase returning nil, a TagGetter must be idempotent in tag identity,
with previously returned containers still expanding to the key set they produced and
treated as read-only. JsVar is the one in-tree initialization case. JaWS does not
serialize JawsGetTag calls.

Jaws.Dirty's doc comment is rewritten: it claimed TagGetters are called with a nil
Request (there is no context now), that Request.Dirty avoids that (the two are
equivalent), and that a non-hashable tag panics — a path TagExpand makes unreachable
by rejecting unusable keys before setDirty sees them. It now documents what a caller
observes: with a Logger the expansion error is logged and the partial result still
applied, without one the call panics before anything is applied. Request.Dirty,
Request.Tag, Request.GetElements and Jaws.Broadcast get the same treatment, and
Request.TagExpanded is marked as the advanced API that neither expands nor validates.

Coverage stays at 99.6%; Request.MustLog gains a direct test now that tag expansion
no longer reaches it incidentally.
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.

Resolve getter tags once during render and preserve the registered tag set bind: HTMLGetterFunc and StringGetterFunc retain the caller tag slice

1 participant