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
Summary
Closes #40. The issue's Ask (verbatim):
Mapped to what was done:
Component.resolve(event, payload, state) -> dictadded — runs mount/hydrate +handle_event(), returnsdehydrate()'s state dict, never callsrender().dispatch()'s internals refactored to share the mount/hydrate + event-handling step withresolve()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 callsdehydrate()afterrender(), sobefore_render()state mutations are still reflected in its returnedstate(see thedoubledfield asserted intest_dispatch_mount_pathand the newtest_dispatch_still_calls_renderregression test).resolve()deliberately does not runbefore_render()/render()at all — it stops athandle_event(), per the Ask.Async counterpart: added
async_resolve()was added alongside the syncresolve(). This repo's ownCLAUDE.mddocumentsasync_dispatch()/async_handle_event()as an existing 0.4.0 feature — it mirrorsdispatch()'s structure exactly (mount/hydrate →await async_handle_event()→ render). Since the issue's Ask is aboutdispatch()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, soasync_resolve()follows the identical shape (await async_handle_event(), thendehydrate(), no render).Design notes
_prepare(state)is a new private helper holding thehydrate()-or-mount()branch, shared byresolve(),async_resolve(),dispatch(), andasync_dispatch().resolve()/async_resolve()each keep their owntry/except+logger.exception(...)wrapper (matching the existingdispatch()/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 fordispatch()'s existing error path).resolve()intentionally does not runbefore_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 forresolve()/async_resolve()plus two new regression tests provingdispatch()/async_dispatch()still callrender()and still reflectbefore_render()mutations)ruff check .— all checks passedruff format --check .— all files formattedty check src/— 47 diagnostics, identical count/content to the pre-change baseline (verified viagit stash); zero diagnostics touchcomponent.pyOut of scope (noted, not folded in)
resolve()were added — the issue's Ask was framework-agnostic core only.🤖 Generated with Claude Code
https://claude.ai/code/session_01Pi1LT1PQ8qo9GcyLeDLiut