Skip to content

lint: flag instance ids built by appending to a generated id - #44

Merged
jackielii merged 1 commit into
mainfrom
feat/lint-keyed-ids
Oct 29, 2026
Merged

jackielii merged 1 commit into
mainfrom
feat/lint-keyed-ids

Conversation

@jackielii

Copy link
Copy Markdown
Owner

Follow-up to #41, so it is based on feat/keyed-targets. Retarget it to main once #41 merges.

What

A new structpages-lint category, keyed. It flags an element id or hx-target that appends a runtime value to a generated id, which is how instance ids were built by hand before Key/KeyTarget:

<li id=f`@{Page.Row |> id}-@{row.ID}`>              // → id={Page.Row |> key(row.ID)}
<li hx-target=f`@{Page.Row |> target}-@{row.ID}`>   // → hx-target={Page.Row |> keytarget(row.ID)}
<li id={rowID + "-" + row.ID}>                       // rowID from structpages.ID

No component has such an id, so the htmx target selectors can't match a request targeting it to Page.Row, and Props can't read the key back. The finding suggests Key/KeyTarget (gsx |> key(k) / |> keytarget(k)), whose key Props reads with KeyOf.

How it matches (no heuristics)

  • Only attribute values. It reads every id and hx-target value (any htmx spelling) on an HTML element in .gsx and .templ, part by part. A gsx f`…` template splits into its text and interpolations (new gsxscan.Values). An expression value splits at string +. Each expression joins exactly to its generated code through gsx /*line*/ directives or templ's source map, as region does (token-compared, so a failed join drops the attribute).
  • What counts as a generated id. A call of ID/IDTarget/Key/KeyTarget (filter, hand-written, or *StructPages method), or a local variable assigned once, from such a call. A local assigned once from another expression stands for that expression. "Once" means it's declared with a value and never assigned again, ranged over with =, or address-taken (&v, pointer-receiver method).
  • Fixed role vs instance key, the one design decision to confirm. What follows the generated id decides. A compile-time constant (literal text like -document, or a named const) is the same in every render, so the id names a sub-element and isn't flagged. Anything else (variable, field, call) varies per render, so it's an instance id and is flagged. This uses the type checker's constant-ness, not the suffix's spelling. One case it treats as an instance key: a role passed in at runtime (e.g. a role string param).
  • id vs hx-target.
    • In id, the whole value is the id: the generated id must start it, and any runtime part after it counts.
    • In hx-target, the value is a selector. Only a runtime value inside the #id token counts: after literal CSS identifier characters that extend it (escapes handled), before any character that ends it. So f`@{X |> target} [data-id="@{k}"]` isn't flagged.
    • A runtime value directly after the target (@{X |> target}@{sel}) might start another compound, so it isn't flagged.
    • ID/Key count in hx-target only after a literal #.
  • Keyed ids too. f`@{X |> key(k)}-@{j}` is flagged: KeyOf would read -j as part of the key. f`@{X |> key(k)}-title` isn't.
  • Not checked:
    • Component tags' attributes, which are props (same as region).
    • Ids built outside the template and passed in.
    • fmt.Sprintf.
    • Text before the generated id.

Real-world run (eos @ 40aaf5a91)

8 findings, all genuine instance ids (groups/index.gsx ×4, users/person.gsx, datalinks/page.gsx, files/derivatives.gsx, files/viewer.gsx). The other 17 hand-built ids in eos are all props of design-system components (<input.Input id=…>, <button.Button hx-target=…>, <messageview.Scroller id=…>, <pickbox.Root id=…>), which this check skips, like region. Covering them precisely means following the prop into the component and checking that it reaches an element's id. That's a separate change, if you want it.

Tests

  • TestKeyed_Integration (testdata/keyed): gsx app/ and templ tapp/ fixtures with 24 flagged lines, plus quiet cases (constant roles, a role on a keyed id, another selector compound, other attributes, a reassigned variable, a component prop, suppression).
  • TestRuntimeInToken / TestEndsWithHash unit-test the selector token scan, including CSS escapes.
  • cd tools/lint && go test ./...: everything passes except TestRegion_Integration, which fails as on main and the base branch (the same 8 .templ findings missing; this PR doesn't change them). The new templ fixture was generated with templ v0.3.1020 and is checked.
  • golangci-lint run ./... in tools/lint: the 7 pre-existing issues, none new.

Docs: a keyed row and a "How keyed decides" section in docs/lint.md, a row in skills/structpages/reference.md §Lint, and a pointer from the keyed-components section of docs/htmx.md. No release.

@jackielii
jackielii added this pull request to stack #45 October 29, 2026 20:38
@codecov

codecov Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.72%. Comparing base (e26ca64) to head (d3d6dd2).

Additional details and impacted files
@@           Coverage Diff           @@
##             main      #44   +/-   ##
=======================================
  Coverage   95.72%   95.72%           
=======================================
  Files          20       20           
  Lines        1870     1870           
=======================================
  Hits         1790     1790           
  Misses         48       48           
  Partials       32       32           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Base automatically changed from feat/keyed-targets to main October 29, 2026 20:42
A component rendered per item used to get its instance id by hand:
id=f`@{Page.Row |> id}-@{row.ID}`, or rowID + "-" + row.ID with rowID
from structpages.ID. That id is no component's, so the htmx target
selectors can't match a request targeting it to Page.Row, and Props
can't read the key back. Key and KeyTarget generate the instance's id.

The new keyed check reads every id and hx-target value on an HTML
element in .gsx and .templ, part by part: a gsx f`…` template's text
and interpolations (gsxscan.Values), an expression value split at
string concatenation. Each expression joins exactly to its generated
code, through gsx's /*line*/ directives or templ's source map, as for
region. A part is a generated id when it is a call of ID, IDTarget, Key
or KeyTarget (filter, hand-written or *StructPages), or a local
variable assigned once, from one; a local assigned once from another
expression stands for it.

What follows decides it. A compile-time constant (-document, a named
constant) is the same in every render: a sub-element's id, not
flagged. Anything else is a runtime value, so the id is an instance's.
In an id the generated id must start the value. In an hx-target the
runtime value must sit inside the #id's token, after literal identifier
text extending it, so another compound of the selector doesn't count;
ID and Key count there only after a literal "#". A Key id extended at
runtime is flagged too: KeyOf reads the extra text as part of the key.

Component tags' attributes are props and aren't checked, as for region.
On eos the check reports 8 ids; the 17 other hand-built ones are props
of design-system components (<input.Input id=…>).
@jackielii
jackielii force-pushed the feat/lint-keyed-ids branch from bf49dcf to d3d6dd2 Compare October 29, 2026 20:42
@jackielii
jackielii merged commit 453fcdc into main Oct 29, 2026
16 checks passed
@jackielii
jackielii deleted the feat/lint-keyed-ids branch October 29, 2026 20:47
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.

1 participant