Skip to content

feat: Key, KeyTarget and KeyOf for one instance of a keyed component - #41

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

jackielii merged 1 commit into
mainfrom
feat/keyed-targets

Conversation

@jackielii

Copy link
Copy Markdown
Owner

Problem

A component rendered once per item (a table row, a card) had one id for every instance. Apps hand-built @{Page.Row |> id}-@{key} ids (EOS has 14). A request targeting one instance matched no component and swapped the whole Page into the row, and Props had no way to tell which item was wanted, so each keyed component needed its own route.

API (additive)

func Key(ctx context.Context, v any, key string) (string, error)       // "list-row--s3"
func KeyTarget(ctx context.Context, v any, key string) (string, error) // "#list-row--s3"
func (sp *StructPages) Key(v any, key string) (string, error)
func (sp *StructPages) KeyTarget(v any, key string) (string, error)
func KeyOf(t RenderTarget) string // "" when the target isn't keyed
key       = "github.com/jackielii/structpages.Key"
keytarget = "github.com/jackielii/structpages.KeyTarget"
<tr id={ Page.Row |> key(row.ID.String()) }>
  <button hx-get={ Page{} |> url } hx-target={ Page.Row |> keytarget(row.ID.String()) }>Reload</button>
</tr>
case t.Is(p.Row):
	id, err := uuid.Parse(structpages.KeyOf(t)) // client input: parse it, then authorise it

Like React's key, the key is the item's identity. Unlike React's, Props can read it back.

Behaviour

  • Id format: the component's id, then --, then the key. Bytes outside [A-Za-z0-9-] are written as _XX, _ included. Only canonical escapes decode, so each key has exactly one id.
  • Errors: an empty key, or a plain string in place of a component.
  • Length budget: the key doesn't count toward it.
  • Selectors: both htmx selectors share htmxTarget. A keyed id matches only exactly: the part before -- must be a generated id.
    • For one of the served page's components, the target Is that component and carries the key.
    • For another page's component or a standalone function, the target is unmatched but Is that component and carries the key.
    • Keyed ids skip the loose name and suffix passes, which would match a key ending in a component's name to that component.
  • KeyOf: the RenderTarget interface is unchanged. The built-in targets hold the key in a field and gain no method, so Props' dynamic-type injection fills parameters as before. A custom target opts in with Key() string.

Compatibility

  • Unkeyed ids: they match exactly as before.
  • Hand-written ids of keyed form: this covers anything with a valid key after --, BEM-style card--active included. They now match only exactly, so one that used to match a component loosely renders Page.
  • Function ids: a function's id takes its prefix from the last element of its import path. When that element has hyphens of its own (my-Lib kebab-cased to my--lib), the new idSegment collapses the runs and trims the ends. This changes the ids of functions in such packages only, so that generated ids never contain --.

Lint

structpages-lint checks Key/KeyTarget (package functions, sp. methods, and the key/keytarget filters) the same way it checks ID/IDTarget, for both idfor and region.

Testing

  • Root module: the suite passes, and the new and changed functions are fully covered.
    • Round trips through both selectors, including keys with --, -, _ and non-ASCII bytes.
    • Keyed ids of shared regions (another page's component, a standalone function).
    • Exact-only matching, malformed and non-canonical keys, and a package whose path kebab-cases to --.
    • Unchanged injection for interface parameters asking for Key() string.
  • tools/lint: adds Key fixtures to testdata/region (gsx) and testdata/render. TestRegion_Integration was already failing on main because 8 .templ findings are missing. It fails on exactly the same 8 here, and all the new gsx findings are present.
  • Independent review: found three defects, all fixed with tests:
    • target injection for Key() interfaces;
    • -- in function ids from hyphenated package paths;
    • non-canonical escapes being accepted.

Release

After merge, release v0.11.0 and tools/lint/v0.1.19.

A component rendered once per item (a table row, a card) had one id for
every instance, so apps hand-built "@{Page.Row |> id}-@{key}" ids. A
request targeting one of them matched no component and rendered Page
into the row, and Props had no way to learn which item was wanted.

Key(ctx, v, key) and KeyTarget generate the id of one instance: the
component's id as ID generates it, "--", and the key with every byte
outside [A-Za-z0-9-] written as _XX ("_" included), so the id needs no
CSS escaping and the key round-trips; only canonical escapes decode, so
a key has one id. A plain string, which names no component, and an
empty key are errors. *StructPages gets Key and KeyTarget too, and the
repo's gsx.toml registers them as the key and keytarget filters.

Generated ids never contain "--", so the first one ends the component
id. A function's id took its prefix from the last import path element,
which can hold hyphens of its own ("my-Lib" kebab-cased to "my--lib");
idSegment now collapses hyphen runs and trims the ends, which changes
the ids of functions in such packages only.

KeyOf(t) returns the key a RenderTarget carries. The interface is
unchanged: the built-in targets hold the key in a field and gain no
method, so Props, which takes the target at its dynamic type, fills its
parameters as before; a custom target opts in with a Key() string
method. Like React's key, the key is the item's identity; unlike it,
Props can read it back, so it is client input to authorise.

Both htmx selectors now go through htmxTarget. A keyed id matches only
exactly: its component id must be a generated id of the served page (the
target Is it), or of another page's component or a standalone function
(unmatched, but Is it). Keyed ids skip the loose name and suffix passes,
which would match a key ending in a component's name to that component.
Unkeyed ids match as before.

structpages-lint checks Key/KeyTarget like ID/IDTarget: page resolution
(idfor) and borrowed regions (region).
@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 (eb01e55) to head (bdc622b).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #41      +/-   ##
==========================================
+ Coverage   95.45%   95.72%   +0.26%     
==========================================
  Files          18       20       +2     
  Lines        1759     1870     +111     
==========================================
+ Hits         1679     1790     +111     
  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.

@jackielii
jackielii added this pull request to stack #45 October 29, 2026 20:38
@jackielii
jackielii merged commit e26ca64 into main Oct 29, 2026
16 checks passed
jackielii added a commit that referenced this pull request Oct 29, 2026
camelToKebab lowercased every rune with r | 0x20, which is only right for
ASCII capitals. An underscore (0x5F) became DEL (0x7F), so a page field
or component named Row_Edit got the id "row\x7f-edit", and letters
outside Latin-1 were garbled ("Größe" became "gröÿe"). It also compared
bytes, not runes, around multi-byte letters.

It now walks runes and lowercases with unicode.ToLower, finding word
starts with unicode.IsUpper/IsLower. Underscores and hyphens separate
words: "Row_Edit" and "row_edit" become "row-edit", and a hyphenated
import path element such as "ui-Kit" becomes "ui-kit" rather than
"ui--kit". Hyphens never repeat or start or end the result.

Ids of names made of ASCII letters and digits are unchanged, checked
against the old function over 100,000 random names. Ids change only for
names with underscores, non-ASCII letters, or a function's package path
element with leading, trailing or doubled hyphens or a hyphen before a
capital. Two names that now kebab-case alike on one tree (RowEdit and
Row_Edit) are reported by Mount's id uniqueness check.

idSegment, which #41 added to collapse hyphen runs after camelToKebab,
is now camelToKebab itself and is removed.
jackielii added a commit that referenced this pull request Oct 29, 2026
camelToKebab lowercased every rune with r | 0x20, which is only right for
ASCII capitals. An underscore (0x5F) became DEL (0x7F), so a page field
or component named Row_Edit got the id "row\x7f-edit", and letters
outside Latin-1 were garbled ("Größe" became "gröÿe"). It also compared
bytes, not runes, around multi-byte letters.

It now walks runes and lowercases with unicode.ToLower, finding word
starts with unicode.IsUpper/IsLower. Underscores and hyphens separate
words: "Row_Edit" and "row_edit" become "row-edit", and a hyphenated
import path element such as "ui-Kit" becomes "ui-kit" rather than
"ui--kit". Hyphens never repeat or start or end the result.

Ids of names made of ASCII letters and digits are unchanged, checked
against the old function over 100,000 random names. Ids change only for
names with underscores, non-ASCII letters, or a function's package path
element with leading, trailing or doubled hyphens or a hyphen before a
capital. Two names that now kebab-case alike on one tree (RowEdit and
Row_Edit) are reported by Mount's id uniqueness check.

idSegment, which #41 added to collapse hyphen runs after camelToKebab,
is now camelToKebab itself and is removed.
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