Skip to content

feat!: v0.9.0 — the request says what it wants; Props decides - #36

Merged
jackielii merged 6 commits into
mainfrom
feat/v0.9.0-render-flow
Oct 16, 2026
Merged

jackielii merged 6 commits into
mainfrom
feat/v0.9.0-render-flow

Conversation

@jackielii

@jackielii jackielii commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

The request says what it wants. Props is the normal page flow; returning an error breaks it.

The new docs page docs/rendering.md ("How a Request Is Rendered") lays out the whole model with a flow diagram. Start the review there.

Breaking

  • No default target selector. Without WithTargetSelector, every request wants Page. htmx apps opt in with HTMXRenderTarget (htmx 1/2) or HTMXv4RenderTarget (htmx 4). The four htmx 2 examples now opt in.
  • Selection goes through Is only. After Props returns no error, the framework asks the target Is(m) for each of the page's components and renders the one it matches, else Page.
    • Custom RenderTarget types take part in selection.
    • A target that matches several components is an error.
  • Is has no side effects, and the "targeted but not handled" error is gone. Recognising a target and returning no error now renders Page.
  • Mount checks component parameters for ambiguity, with Props' result types counted as provided.

New

  • Is(OtherPage.Method): on an unmatched htmx target, matches another page's component by its exact generated id (htmxUnmatchedTarget, now in htmx.go).
  • RenderComponent(c, more...): renders several components in order as one response, for updating several regions at once.
  • Request args: WithRequestArgs(providers...) and a RequestArgs() []any page method.
    • Providers have the shape func(*http.Request[, *PageNode]) (T, error).
    • Each runs lazily, at most once per request, with the nearest page's provider winning.
    • A provider's error counts as an error from the method that needed the value.
  • Components can now take per-request values and request args as well as Props' results.

Lint (tools/lint)

  • idfor checks RenderTarget.Is arguments, flagging non-function arguments such as t.Is(p.Content()) and unmounted receivers.
  • A new selector check flags a Mount with no WithTargetSelector when its pages' templates use htmx. It skips _test.go files and option lists it can't resolve.
  • A new region check (.gsx) flags a page's component that puts another page's component id in id or hx-target/hx-target:inherited, chain form included. It isn't flagged when the page's Props handles Is for that component, and <hx-partial> isn't flagged. On eos at 225904df^ it reports all 12 sites across the 5 pages that commit fixed; at eos HEAD it reports nothing.
  • A Mount whose options end in a spread (opts...) now builds its tree, so those pages are linted. Before, they were silently skipped.

Docs

  • New: docs/rendering.md, with worked examples around Is and a section on targets that belong to another page. That section includes a middleware that retargets boosted navigation to body, and a development middleware that reports borrowed regions. Both are pinned by rendering_examples_test.go.
  • Updated: htmx, dependency injection (a new request-args section), API, concepts, flows, error handling, gsx, templ and testing pages, both READMEs, the skill files and CLAUDE.md.
  • The docs-site branch (already pushed, 379296a) adds the sidebar entry and the Mermaid plugin. Merging this PR to main triggers the deploy.

Choices for review

  • Shadowing: a page's RequestArgs provider overrides an outer provider of the same type, rather than being an error.
  • Docs examples: several that used RenderComponent now return several Props values instead, so they show the normal flow.

Verification

  • go test ./..., golangci-lint and gopls are clean.
  • All examples pass, including the lint-misuse snapshot, which now shows the selector finding.
  • The tools/lint tests pass.
  • The site builds with no dead links, and I checked in a browser that the diagram renders.
  • eos at 798e84ed, run against this branch through a go.work: 344 packages ok, 0 failures, and the new linter reports nothing.

Release

v0.9.0 and tools/lint/v0.1.17, after review.

https://claude.ai/code/session_01Dp3Jmu2ePyCME2yG9yd672

The render flow, stated once: the target selector reads what the request
wants, Props is the normal flow, and returning an error breaks it. With
no error, the framework renders the component the target Is, else Page.

Breaking:
- No default target selector. Without WithTargetSelector every request
  wants Page; htmx apps opt in with HTMXRenderTarget (htmx 1/2) or
  HTMXv4RenderTarget (htmx 4).
- Selection asks the RenderTarget Is for each of the page's components,
  so custom targets take part; a target that Is several is an error.
  Is has no side effects, and the "targeted but not handled" error is
  gone: recognising a target and returning no error renders Page.
- Mount checks component parameters for ambiguity too, with Props'
  results counted as provided.

New:
- On an unmatched htmx target, Is(OtherPage.Method) matches another
  page's component by its exact generated id (htmxUnmatchedTarget).
- RenderComponent(c, more...) renders several components in order, for
  updating several regions in one response.
- Request args: WithRequestArgs and a RequestArgs() page method register
  per-request providers, func(*http.Request[, *PageNode]) (T, error),
  run lazily once per request, nearest page first. Components can now
  take per-request values and request args as well as Props' results.

Lint: idfor checks RenderTarget.Is arguments; a new selector check flags
a Mount without WithTargetSelector whose pages' templates use htmx; a
Mount whose options end in a spread now builds its tree.

Docs: new "How a Request Is Rendered" page with a flow diagram; htmx,
DI (request args), API, flows, error handling, skill and CLAUDE.md
updated.

Claude-Session: https://claude.ai/code/session_01Dp3Jmu2ePyCME2yG9yd672
After the flow diagram, trace one dashboard page through a table of
requests (its own components, a standalone component, another page's
region, an id nobody owns), then the subtle cases: recognised but not
rendered, a page without Page, and a custom selector built on ID.
rendering_examples_test.go pins every row and case.

Claude-Session: https://claude.ai/code/session_01Dp3Jmu2ePyCME2yG9yd672
@codecov

codecov Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.42683% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.32%. Comparing base (b5ffa6b) to head (4e54154).

Files with missing lines Patch % Lines
request_args.go 90.67% 7 Missing and 4 partials ⚠️
parse.go 97.26% 1 Missing and 1 partial ⚠️
id_for.go 50.00% 0 Missing and 1 partial ⚠️
struct_pages.go 98.91% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #36      +/-   ##
==========================================
+ Coverage   95.21%   95.32%   +0.10%     
==========================================
  Files          16       17       +1     
  Lines        1547     1754     +207     
==========================================
+ Hits         1473     1672     +199     
- Misses         44       49       +5     
- Partials       30       33       +3     

☔ 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.

Split the "unowned id" row into its two real causes, with fixes: a boosted
link inheriting another page's region target (fix the markup; safety-net
middleware retargets boosted navigation to body), and a page reusing
another page's region id (own the id, or recognise it with Is). The
middleware recipe uses only public API and is pinned by a test.

Claude-Session: https://claude.ai/code/session_01Dp3Jmu2ePyCME2yG9yd672
A boosted form inside a region posting to an action handler gets a
fragment for that region back; retargeting it to body was wrong.

Claude-Session: https://claude.ai/code/session_01Dp3Jmu2ePyCME2yG9yd672
Flags, in a page's own gsx component, an id={…Q.M |> id} on an element,
or an hx-target / hx-target:inherited={…Q.M |> target}, where Q.M is
another page's component mounted in the same tree and the page's Props
doesn't handle t.Is(Q.M). Requests from that element come back to the
page targeting an id it has no component for, so it renders its Page
into the region. <hx-partial hx-target> (an out-of-band swap) and other
attributes are not flagged. The call is joined to its attribute through
gsx's /*line*/ directives; templ isn't covered.

On eos at 225904df^ it reports the 12 sites of the 5 pages that commit
fixed; at HEAD, nothing.

Docs: rendering.md's "Seeing it happen" gains a development middleware
that reports a non-boosted partial request targeting another page's
component, pinned by rendering_examples_test.go.

Claude-Session: https://claude.ai/code/session_01Dp3Jmu2ePyCME2yG9yd672
Library (from an independent review):
- An unmatched htmx target never Is a method of the served page's own
  type, so a type mounted twice renders Page for the other mount's
  region instead of its own component.
- Components get the writer, request and target only for parameters of
  exactly those types, so an existing io.Writer or any parameter is still
  filled from Props' results or WithArgs as before v0.9.0.
- Parameters fill deterministically: exact type first, then the first
  assignable value in order (Props' results, request values, *PageNode),
  instead of map order. The Mount check models the same order.
- An exact id of another page's component skips the loose matching, so
  it isn't claimed by a suffix match on the served page.
- Request-arg RenderComponent/ErrSkipPageRender are honoured in both
  extended ServeHTTP forms; a selector error there reaches the handler.
- render keeps a Content-Type that Props set; text/html is the default.
- Is with a bound value of a generic page type matches (outside generic
  code); an id -> component index replaces per-request tree walks.

Linter:
- region no longer flags an element whose own request goes to another
  page (lazy loads, cross-page forms); covers templ files generated
  without a version line or with explicit semicolons; compares values
  as tokens (multi-line, comments); caches template parses per process;
  reports at the stage name's column; follows the runtime owner of
  promoted methods.
- selector treats WithTargetSelector(nil) as no selector.
- idfor wording: "method value"; softer message for non-function Is
  arguments, which a custom RenderTarget may accept.
- route-literal skips the route argument of Mount/Parse calls.

Docs: fill order and exact-type rule, Mount check scope, status with a
notice (Content-Type first), provider errors, diagram error edges,
generic Is limitation, anchors and examples fixed.

Claude-Session: https://claude.ai/code/session_01Dp3Jmu2ePyCME2yG9yd672
@jackielii
jackielii merged commit 1859832 into main Oct 16, 2026
16 checks passed
@jackielii
jackielii deleted the feat/v0.9.0-render-flow branch October 16, 2026 21:46
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