Skip to content

feat(litestar): htmx HX-Request support + fix JinjaX doc reference (#39) - #42

Merged
fsecada01 merged 1 commit into
masterfrom
feat/component-framework-phase-39-litestar-htmx
Jul 20, 2026
Merged

feat(litestar): htmx HX-Request support + fix JinjaX doc reference (#39)#42
fsecada01 merged 1 commit into
masterfrom
feat/component-framework-phase-39-litestar-htmx

Conversation

@fsecada01

Copy link
Copy Markdown
Owner

Summary

Closes #39. Two related gaps in the Litestar adapter reported while integrating this framework into a Litestar+JinjaX app with a stateful htmx-swapped component.

Scope items

  1. Docs bug fixeddocs/site-pages/litestar-guide.html (lines ~625, ~884, ~946) referenced a nonexistent Jinja2Renderer imported from component_framework.adapters.jinjax_renderer. Replaced all three occurrences with the real JinjaxRenderer(catalog) API (constructed from a jinjax.Catalog instance, matching the working pattern already used in examples/fastapi_example.py). The third occurrence (inside the tests/test_litestar_adapter.py code sample) had an unused/dead import — removed, since the real test file uses MockRenderer and never imported this at all.
  2. Real capability gap addressedadapters/litestar.py's component_endpoint and stream_component_endpoint previously always returned the JSON envelope ({"html", "state", "component_id", "slots"}), which breaks a plain hx-swap (raw JSON would splat into the DOM).
  3. Implemented option 1 from the issue's "Ask": added HX-Request header content negotiation.
    • component_endpoint: when the request carries HX-Request, returns Response(content=result["html"], media_type="text/html") — the rendered fragment, ready for hx-swap. Without the header, behavior is byte-for-byte unchanged (JSON envelope, default).
    • stream_component_endpoint: when HX-Request is present, each SSE frame is now formatted as raw HTML lines (data: <line> per htmx's SSE-extension convention) instead of a JSON-wrapped frame, so sse-swap works directly. Without the header, frames remain the existing JSON-per-frame format (regression-covered).
  4. jinjax added to the litestar extra in pyproject.toml (litestar = ["litestar>=2.0", "jinja2>=3.1", "jinjax>=0.41"]) so JinjaxRenderer is actually importable once you pip install component-framework[litestar].

Tests

TDD red→green. Added to the existing (previously untracked-by-justfile) tests/test_litestar_adapter.py and tests/test_litestar_sse.py:

  • TestHtmxContentNegotiation: HX-Request → HTML fragment (mount + event dispatch); no header → JSON envelope unchanged.
  • TestHtmxStreamContentNegotiation: HX-Request → HTML-per-frame SSE; no header → JSON-per-frame SSE unchanged.

All three new/failing assertions were confirmed to fail for the right reason (plain JSON returned regardless of header) before the implementation was added.

Verification

  • ruff check . — all checks passed
  • ruff format --check . — 82 files already formatted
  • pytest tests/ — 482 passed (full suite, not just new tests)
  • ty check src/ — exit 0; 17 pre-existing warn-level diagnostics unrelated to this change (django_websocket.py, composition.py, form.py, testing.py — none in litestar.py)

Out of scope (noted, not touched)

While reading docs/site-pages/litestar-guide.html I noticed it also references component_stream_endpoint and imports component_websocket_endpoint from component_framework.adapters.litestar (the real names/locations are stream_component_endpoint in adapters/litestar.py, and component_websocket_endpoint actually lives in adapters/litestar_websocket.py, not adapters/litestar.py). This is a separate doc-accuracy issue from the one filed here — flagging as a follow-up rather than folding it into this diff.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Pi1LT1PQ8qo9GcyLeDLiut

#39)

Adds content negotiation to the Litestar adapter so plain htmx (hx-post/
hx-swap, and the SSE extension) can drive components without the
proprietary component-client.js: when a request carries the HX-Request
header, component_endpoint and stream_component_endpoint now return the
rendered HTML fragment directly instead of the JSON envelope; the JSON
envelope remains the default for backward compatibility.

Also fixes docs/site-pages/litestar-guide.html, which referenced a
nonexistent Jinja2Renderer instead of the real JinjaxRenderer(catalog)
API, and adds jinjax to the litestar extra in pyproject.toml so
JinjaxRenderer is actually importable for Litestar consumers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pi1LT1PQ8qo9GcyLeDLiut
@fsecada01

Copy link
Copy Markdown
Owner Author

Adversarial review — PR #42 (feat/component-framework-phase-39-litestar-htmx)

Reviewed via gh pr diff / gh pr view against master. 5 files changed, +115/-7.

Overview

Adds HX-Request content negotiation to the Litestar adapter's component_endpoint and stream_component_endpoint, fixes three broken Jinja2Renderer doc references in docs/site-pages/litestar-guide.html (replaced with the real JinjaxRenderer(catalog) API), and adds jinjax to the litestar extra in pyproject.toml. Matches the four scope items from issue #39.

Correctness

  • Header case-insensitivity verified: request.headers.get("HX-Request") is safe — confirmed litestar.datastructures.headers.Headers subclasses CIMultiDictProxy (genuinely case-insensitive), consistent with the adapter's pre-existing request.headers.get("content-type", "") usage a few lines up.
  • component_endpoint: HX-Request branch returns result["html"] as text/html before falling through to the unchanged JSON path — order is correct (JSON path still fully computes result["state"] first, so no wasted/duplicated work either way).
  • stream_component_endpoint: want_html is computed once outside the generator (correct — avoids re-checking per frame) and branches per-frame between _format_html_sse_frame(frame["html"]) and the existing format_sse_frame(frame). SSE multi-line framing (data: <line> per line, blank-line terminator) is spec-correct.
  • Regression coverage confirmed: default (no header) path is asserted unchanged in both the HTTP and SSE test files, and the full 482-test suite plus ruff/ty gates pass per the PR description.

Doc fix quality

  • All three Jinja2Renderer occurrences fixed correctly, using the same Catalog() + catalog.add_folder(...) + JinjaxRenderer(catalog) pattern already proven in examples/fastapi_example.py — good, it's not an invented API this time.
  • The third occurrence (inside the tests/test_litestar_adapter.py code sample) was simply an unused import; removing it rather than swapping it in was the right call since the sample already uses MockRenderer.

Test coverage

  • TestHtmxContentNegotiation and TestHtmxStreamContentNegotiation both cover the HX-Request and non-HX-Request paths, including event dispatch (not just mount) for the HTTP case. Solid TDD red→green discipline per the PR description.
  • Minor style nit: test_hx_request_stream_emits_html_not_json uses pytest.raises((json.JSONDecodeError, AssertionError)) wrapping a json.loads + nested assert to express "this payload is not the JSON envelope." It's logically sound (traced through: parse failure raises JSONDecodeError as usual; accidental successful parse without stream_done raises AssertionError which is swallowed by pytest.raises; accidental successful parse with stream_done present raises nothing and correctly fails the test) but is more convoluted than it needs to be — assert not (safely_parse_json(p) and "stream_done" in parsed) style would read more clearly. Not blocking.

Gaps / follow-ups (non-blocking)

  1. State is silently dropped on the HX-Request path. This is exactly what the issue asked for, but it's worth calling out explicitly: a consumer driving a stateful component purely via hx-post/hx-swap has no built-in way to carry the component's serialized state forward across turns (no hidden state field, no session wiring) — they'd need to embed it themselves (e.g., a hidden input in the component's own template + hx-include) or move to a server-side session store. Neither this PR nor the guide documents that trade-off. Suggest a short callout in litestar-guide.html in a follow-up.
  2. Docstrings not updated. component_endpoint's and stream_component_endpoint's docstrings still only describe the JSON-envelope contract; the new HX-Request branch isn't mentioned there, only in the two new private helper docstrings. A one-line addition to each public docstring would make the behavior discoverable without reading the source.
  3. Presence-only check vs. value check. _wants_html_response treats any HX-Request header as truthy rather than checking == "true". This matches the issue's literal wording ("carries an HX-Request header") and htmx always sends "true", so this is fine in practice — flagging only because a stricter check would be marginally more defensive against a hand-rolled client sending HX-Request: false.
  4. Already called out in the PR body itself and appropriately left out of scope: docs/site-pages/litestar-guide.html also references a nonexistent component_stream_endpoint name and imports component_websocket_endpoint from the wrong module (adapters.litestar instead of adapters.litestar_websocket). Agreed this belongs in a separate follow-up, not folded into this diff.

Security

No new attack surface: the HX-Request path returns the same result["html"] string that was already being shipped inside the JSON envelope's "html" key and already being assigned to innerHTML client-side by component-client.js — XSS exposure is unchanged, not introduced. CSRF posture for Litestar is unchanged (still host-integration-only, per the project's existing SECURITY_CSRF.md stance) and this PR doesn't touch it.

Verdict

No blocking issues found. Implementation is correct, scoped tightly to the four issue items, backed by real regression tests, and all quality gates (ruff check/format, full 482-test pytest suite, ty check) are green. The three follow-ups above are suggestions, not blockers, for a human to weigh before or after merge.

@fsecada01 fsecada01 added enhancement New feature or request documentation Improvements or additions to documentation labels Jul 20, 2026
@fsecada01 fsecada01 self-assigned this Jul 20, 2026
@fsecada01
fsecada01 merged commit 9a43d39 into master Jul 20, 2026
7 checks passed
fsecada01 added a commit that referenced this pull request Jul 21, 2026
…nt.resolve()

PRs #41 (#40) and #42 (#39) shipped before the Epic B work but were never
added to the changelog, so the 0.6.0b0 section and release notes omitted
two real features. Backfilling now, discovered via `git diff v0.5.1b0..v0.6.0b0`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pi1LT1PQ8qo9GcyLeDLiut
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Litestar adapter: JSON-only endpoint contract blocks plain htmx hx-swap; JinjaX+Litestar docs cite nonexistent Jinja2Renderer

1 participant