Skip to content

feat(core): add Component.resolve() state-only dispatch path (#40) - #41

Merged
fsecada01 merged 1 commit into
masterfrom
feat/component-framework-phase-40-resolve-state
Jul 20, 2026
Merged

feat(core): add Component.resolve() state-only dispatch path (#40)#41
fsecada01 merged 1 commit into
masterfrom
feat/component-framework-phase-40-resolve-state

Conversation

@fsecada01

Copy link
Copy Markdown
Owner

Summary

Closes #40. The issue's Ask (verbatim):

A lower-level entry point that stops after handle_event() and returns state without calling render() — e.g. Component.resolve(event, payload, state) -> dict (state only), so dispatch() can become resolve() + render() internally, and callers who only need the former aren't forced to pay for the latter.

Mapped to what was done:

  • Component.resolve(event, payload, state) -> dict added — runs mount/hydrate + handle_event(), returns dehydrate()'s state dict, never calls render().
  • dispatch()'s internals refactored to share the mount/hydrate + event-handling step with resolve() via a private _prepare() helper, so the duplication the issue points at is gone.
  • dispatch()'s external behavior/return shape is byte-for-byte unchanged — it still calls dehydrate() after render(), so before_render() state mutations are still reflected in its returned state (see the doubled field asserted in test_dispatch_mount_path and the new test_dispatch_still_calls_render regression test). resolve() deliberately does not run before_render()/render() at all — it stops at handle_event(), per the Ask.

Async counterpart: added

async_resolve() was added alongside the sync resolve(). This repo's own CLAUDE.md documents async_dispatch()/async_handle_event() as an existing 0.4.0 feature — it mirrors dispatch()'s structure exactly (mount/hydrate → await async_handle_event() → render). Since the issue's Ask is about dispatch() forcing an unwanted render, the same gap exists on the async path for any async adapter (FastAPI, Litestar, WebSocket) doing the same "resolve state → server work → real render" pattern. Leaving it sync-only would just recreate this same issue for async callers, so async_resolve() follows the identical shape (await async_handle_event(), then dehydrate(), no render).

Design notes

  • _prepare(state) is a new private helper holding the hydrate()-or-mount() branch, shared by resolve(), async_resolve(), dispatch(), and async_dispatch().
  • resolve()/async_resolve() each keep their own try/except + logger.exception(...) wrapper (matching the existing dispatch()/async_dispatch() pattern) rather than one calling into the other, so error log messages stay attributed to the method actually invoked (no behavior/logging drift for dispatch()'s existing error path).
  • resolve() intentionally does not run before_render() — that hook only exists as part of rendering, so its absence from the returned state is documented in the docstring, not a bug.

Verification (all green)

  • pytest — full existing suite: 491 passed (was 479 before + 12 new resolve/regression tests = 491)
  • pytest tests/test_component.py — 58 passed (includes new TDD-red-then-green tests for resolve()/async_resolve() plus two new regression tests proving dispatch()/async_dispatch() still call render() and still reflect before_render() mutations)
  • ruff check . — all checks passed
  • ruff format --check . — all files formatted
  • ty check src/ — 47 diagnostics, identical count/content to the pre-change baseline (verified via git stash); zero diagnostics touch component.py

Out of scope (noted, not folded in)

  • No adapter-level (FastAPI/Litestar/Django) convenience wrappers around resolve() were added — the issue's Ask was framework-agnostic core only.
  • No docs/ page update for the new methods beyond docstrings — flagging as a good follow-up if this needs to be discoverable outside the API reference.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Pi1LT1PQ8qo9GcyLeDLiut

dispatch() always paid for a full render() even when a caller only
needed the resolved state to drive further server-side work (e.g. a
DB re-query keyed on the new filter/page value). Extract the
mount/hydrate + handle_event lifecycle shared by dispatch() into
_prepare(), and add resolve()/async_resolve() that run through
handle_event() and return dehydrate()'s state dict without rendering.
dispatch()/async_dispatch() keep their exact external behavior
(dehydrate() still runs after render(), so before_render() mutations
are still reflected in their returned state).

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 #41 (feat/component-framework-phase-40-resolve-state)

Overview

Adds Component.resolve() / Component.async_resolve(): mount-or-hydrate + handle_event()/async_handle_event(), returning dehydrate()'s state dict, with no call to render(). Extracts the mount/hydrate branch shared by resolve()/dispatch()/async_resolve()/async_dispatch() into a new private _prepare(state) helper. dispatch()/async_dispatch() are refactored to use _prepare() but keep their own inline event-handling + render + dehydrate steps (they do not call resolve() internally, despite the PR body/issue phrasing "dispatch() can become resolve() + render()") — this is the right call, not a shortcut: it preserves the existing contract that dehydrate() runs after render(), so before_render() mutations still land in dispatch()'s returned state (correctly regression-tested via the new test_dispatch_still_calls_render / test_async_dispatch_still_calls_render, which assert state["doubled"] survives). Good catch avoiding a real behavioral regression there.

Diff is small and contained: 2 files, +230/-13, all within core/component.py and its test file. No adapter, docs, or public __init__.py export changes — consistent with the "core-agnostic, no scope creep" instruction.

Correctness

  • _prepare()'s docstring says "shared by resolve() and dispatch()" — it's actually shared by four methods (resolve, async_resolve, dispatch, async_dispatch). Cosmetic, but worth a one-line fix so it doesn't mislead a future reader who greps for callers.
  • The exception-logging duplication between resolve()/dispatch() (and their async twins) is a deliberate, documented tradeoff (own PR description calls it out) to keep dispatch()'s existing log-message text ("Error in {cls}.dispatch()") unchanged rather than nesting under resolve()'s wrapper. That's the correct choice for a refactor whose whole premise is "don't change dispatch's observable behavior," and it's verified — no test asserts exact log text for dispatch()'s error path, but preserving the log site is still the more conservative and correct option here.
  • Worth flagging (not blocking): resolve() returns dehydrate()'s output, which strips locked_fields. The issue's own motivating example is "resolve state → do a DB re-query based on the new state." Locked/server-trusted fields (tenant/user/account scoping — exactly the kind of value a re-query would key on) are excluded from the dict resolve() hands back, even though they're still sitting in self.state (per dehydrate()'s existing contract). A consumer who naively does state = component.resolve(...) and then queries off state[...] for a locked field will silently get KeyError/None instead of the real value, and has to know to reach into component.state directly instead. This is consistent with how dispatch()'s returned "state" already behaves (so it's not a new bug), but resolve()'s docstring should probably say so explicitly, since resolve() is being pitched as the entry point for exactly this "build a server-side query from resolved state" pattern. A one-line docstring addendum ("like dispatch(), the returned dict excludes locked_fields — read self.state if you need them") would close this gap cheaply. No test currently exercises resolve() + locked_fields together (there's solid locked_fields coverage elsewhere in tests/test_locked_fields.py, but none of it touches the new methods).
  • resolve()'s "no before_render()" behavior is intentional and documented, and is explicitly regression-tested (test_resolve_does_not_call_render asserts "doubled" not in result). Good.

Code quality / style

  • Matches existing conventions: ClassVar/type hints, logger.exception(...) pattern, docstring style (Args/Returns), naming (_prepare matches the existing _generate_id/_strip_locked_fields private-helper convention).
  • ruff check/ruff format --check reported clean per the PR body; I didn't re-run them myself but the diff shows properly wrapped lines and consistent formatting with the surrounding file.
  • Section comment # ---------- Dispatch ---------- now covers both resolve() and dispatch() methods; a separate # ---------- Resolve ---------- header (mirroring the file's existing section-comment convention used elsewhere, e.g. # ---------- Events ----------) would read more cleanly, though this is a nitpick.

Test coverage

  • Strong TDD discipline: 6 sync resolve() tests + 6 async + 2 dispatch-regression tests (14 new), all meaningfully distinct (mount path, hydrate path, event path, error propagation, "render never called" via spy, return-shape assertion). The "does-not-call-render" tests use a monkeypatch.setattr(comp, "render", ...) spy rather than just checking output shape, which is a solid way to prove the negative rather than inferring it.
  • The two new "still calls render" regression tests on dispatch()/async_dispatch() are the most valuable addition here — they're the ones that would catch a future refactor accidentally routing dispatch() through resolve()'s dehydrate-before-render path (which would have quietly broken before_render()-based state, e.g. doubled).
  • Gap: as noted above, no test combines resolve()/async_resolve() with locked_fields to confirm/document that locked fields are (as expected) stripped from the returned dict. Given tests/test_locked_fields.py already has a house style for this exact kind of assertion (test_dispatch_response_state_excludes_locked_fields, test_async_dispatch_strips_locked_fields), adding a test_resolve_strips_locked_fields there would be cheap and would double as the documentation fix mentioned above.

Risks / backward compatibility

  • Since Component is a widely-subclassed base used by every adapter (FastAPI/Litestar/Django/Flask), the key risk was dispatch()/async_dispatch()'s external contract shifting. Verified via diff + regression tests that both still: (1) call render() unconditionally when reached, (2) dehydrate after render (capturing before_render() mutations), (3) return the same four-key dict shape. No adapter files are touched in this diff, and none should need to be — resolve()/async_resolve() are purely additive.
  • _prepare is a new private method name on Component. Low collision risk (single leading underscore, unlikely to shadow anything in subclasses), but worth a quick mental note since Component is subclassed extensively — nothing in this diff suggests an actual collision, just flagging the surface area grew by one private method.

Minor nits

  • PR body math: "491 passed (was 479 before + 12 new resolve/regression tests = 491)" — the diff actually adds 14 new test methods (6 + 6 + 1 + 1), not 12; 479+14=493 rather than 491, so either the "479" baseline or the "12" count is slightly off. Doesn't affect the shipped code, just a description inaccuracy worth a quick correction for anyone auditing the numbers later.

Verdict

No blocking issues. The core refactor is careful and correctly regression-tested — the decision to not route dispatch() through resolve() internally (despite the issue phrasing suggesting that literal composition) is the right engineering call given the before_render()/dehydrate() ordering constraint, and it's proven by tests rather than asserted. The one substantive thing worth addressing before/after merge is documenting (and ideally testing) that resolve()'s returned dict excludes locked_fields, since that's directly relevant to the "build a DB query from resolved state" use case the issue was filed for.

@fsecada01 fsecada01 self-assigned this Jul 20, 2026
@fsecada01 fsecada01 added the enhancement New feature or request label Jul 20, 2026
@fsecada01
fsecada01 merged commit 6209318 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

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Component.dispatch() couples event-resolution with rendering — no lightweight state-only path

1 participant