Skip to content

Commit d34cc05

Browse files
fsecada01claude
andcommitted
feat: add async event handler support (async_dispatch / async_handle_event)
Closes #10 — async def on_* handlers are now properly awaited: - Component.async_handle_event(): awaits coroutine handlers, works with sync too - Component.async_dispatch(): async entry point for async adapters - Component.handle_event(): now raises ComponentError if handler is async - FastAPI, Litestar, and WebSocket adapters updated to use async_dispatch - 11 new tests covering async handlers, sync fallback, and error propagation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c130578 commit d34cc05

5 files changed

Lines changed: 212 additions & 9 deletions

File tree

src/component_framework/adapters/fastapi.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@ async def component_endpoint(name: str, request: Request) -> JSONResponse:
5858
except Exception as e:
5959
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
6060

61-
# Create and dispatch component
61+
# Create and dispatch component (async to support async on_* handlers)
6262
component = component_cls(**params)
63-
result = component.dispatch(event=event, payload=payload, state=state)
63+
result = await component.async_dispatch(event=event, payload=payload, state=state)
6464

6565
# Serialize state for response
6666
result["state"] = StateSerializer.serialize(result["state"])

src/component_framework/adapters/litestar.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ async def component_endpoint(name: str, request: Request) -> Response:
6060
except Exception as e:
6161
raise HTTPException(status_code=400, detail=f"Invalid state: {e}")
6262

63-
# Create and dispatch component
63+
# Create and dispatch component (async to support async on_* handlers)
6464
component = component_cls(**params)
65-
result = component.dispatch(event=event, payload=payload, state=state)
65+
result = await component.async_dispatch(event=event, payload=payload, state=state)
6666

6767
# Serialize state for response
6868
result["state"] = StateSerializer.serialize(result["state"])

src/component_framework/core/component.py

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Core component base class with lifecycle management."""
22

3+
import inspect
34
import json
45
import logging
56
from typing import TYPE_CHECKING, Any, ClassVar
@@ -145,21 +146,31 @@ def get_optimistic_patch(self, event: str, payload: dict) -> dict | None:
145146

146147
def handle_event(self, event: str, payload: dict):
147148
"""
148-
Route event to handler method.
149+
Route event to a **synchronous** handler method.
150+
151+
For async handlers (``async def on_*``), use :meth:`async_handle_event`
152+
instead. Calling this method with an async handler will raise
153+
:class:`ComponentError`.
149154
150155
Args:
151156
event: Event name (e.g., "increment")
152157
payload: Event data
153158
154159
Raises:
155160
EventNotFoundError: If handler not found
156-
ComponentError: If handler raises exception
161+
ComponentError: If handler raises exception or is async
157162
"""
158163
handler = getattr(self, f"on_{event}", None)
159164

160165
if not handler:
161166
raise EventNotFoundError(f"No handler for event: {event}")
162167

168+
if inspect.iscoroutinefunction(handler):
169+
raise ComponentError(
170+
f"Handler 'on_{event}' is async — use async_dispatch() or "
171+
"async_handle_event() instead of the sync variants."
172+
)
173+
163174
try:
164175
handler(**payload)
165176
except TypeError as e:
@@ -168,6 +179,35 @@ def handle_event(self, event: str, payload: dict):
168179
logger.exception(f"Error handling {event} in {self.__class__.__name__}")
169180
raise ComponentError(f"Error handling {event}") from e
170181

182+
async def async_handle_event(self, event: str, payload: dict):
183+
"""
184+
Route event to handler, awaiting if the handler is async.
185+
186+
Works with both ``def on_*`` and ``async def on_*`` handlers.
187+
188+
Args:
189+
event: Event name (e.g., "increment")
190+
payload: Event data
191+
192+
Raises:
193+
EventNotFoundError: If handler not found
194+
ComponentError: If handler raises exception
195+
"""
196+
handler = getattr(self, f"on_{event}", None)
197+
198+
if not handler:
199+
raise EventNotFoundError(f"No handler for event: {event}")
200+
201+
try:
202+
result = handler(**payload)
203+
if inspect.isawaitable(result):
204+
await result
205+
except TypeError as e:
206+
raise ComponentError(f"Invalid payload for {event}: {e}") from e
207+
except Exception as e:
208+
logger.exception(f"Error handling {event} in {self.__class__.__name__}")
209+
raise ComponentError(f"Error handling {event}") from e
210+
171211
# ---------- Rendering ----------
172212

173213
def get_context(self) -> dict:
@@ -209,7 +249,10 @@ def dispatch(
209249
state: dict | None = None,
210250
) -> dict:
211251
"""
212-
Main entry point for component execution.
252+
Synchronous entry point for component execution.
253+
254+
For components with ``async def on_*`` handlers, use
255+
:meth:`async_dispatch` instead.
213256
214257
Args:
215258
event: Event name to handle
@@ -244,6 +287,52 @@ def dispatch(
244287
logger.exception(f"Error in {self.__class__.__name__}.dispatch()")
245288
raise
246289

290+
async def async_dispatch(
291+
self,
292+
event: str | None = None,
293+
payload: dict | None = None,
294+
state: dict | None = None,
295+
) -> dict:
296+
"""
297+
Async entry point for component execution.
298+
299+
Works with both sync and async event handlers. Use this from async
300+
adapters (FastAPI, Litestar, WebSocket) to support ``async def on_*``
301+
handlers.
302+
303+
Args:
304+
event: Event name to handle
305+
payload: Event data
306+
state: Serialized state to restore
307+
308+
Returns:
309+
Dict with 'html' and 'state' keys
310+
"""
311+
try:
312+
# Lifecycle: mount or hydrate
313+
if state:
314+
self.hydrate(state)
315+
else:
316+
self.mount()
317+
318+
# Handle event if provided
319+
if event:
320+
await self.async_handle_event(event, payload or {})
321+
322+
# Render
323+
html = self.render()
324+
325+
return {
326+
"html": html,
327+
"state": self.dehydrate(),
328+
"component_id": self.id,
329+
"slots": self.render_slots(),
330+
}
331+
332+
except Exception:
333+
logger.exception(f"Error in {self.__class__.__name__}.async_dispatch()")
334+
raise
335+
247336

248337
class StateSerializer:
249338
"""Handles safe serialization/deserialization of component state."""

src/component_framework/core/websocket.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,10 @@ async def _handle_component_event(
134134
if state_str:
135135
state = StateSerializer.deserialize(state_str)
136136

137-
# Create and dispatch component
137+
# Create and dispatch component (async to support async on_* handlers)
138138
params = {"component_id": component_id}
139139
component = component_cls(**params)
140-
result = component.dispatch(event=event, payload=payload, state=state)
140+
result = await component.async_dispatch(event=event, payload=payload, state=state)
141141

142142
# Serialize state
143143
result["state"] = StateSerializer.serialize(result["state"])

tests/test_component.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,28 @@ def before_render(self):
3737
self.state["doubled"] = self.state.get("value", 0) * 2
3838

3939

40+
class AsyncSampleComponent(Component):
41+
"""Test component with async event handlers."""
42+
43+
template_name = "async_sample.html"
44+
45+
def mount(self):
46+
super().mount()
47+
self.state["value"] = self.params.get("initial", 0)
48+
49+
async def on_update(self, value: int):
50+
self.state["value"] = value
51+
52+
async def on_failing_event(self):
53+
raise ValueError("intentional async error")
54+
55+
def on_sync_event(self, value: int = 0):
56+
self.state["value"] = value
57+
58+
def before_render(self):
59+
self.state["doubled"] = self.state.get("value", 0) * 2
60+
61+
4062
# ---------- Component Lifecycle ----------
4163

4264

@@ -236,3 +258,95 @@ def test_serialize_non_json_types_uses_str(self):
236258
def test_deserialize_invalid_json_raises(self):
237259
with pytest.raises(Exception):
238260
StateSerializer.deserialize("not json")
261+
262+
263+
# ---------- Async Event Handling ----------
264+
265+
266+
class TestAsyncHandleEvent:
267+
@pytest.mark.asyncio
268+
async def test_async_handle_event_routes_to_async_handler(self):
269+
Component.renderer = MockRenderer()
270+
comp = AsyncSampleComponent()
271+
comp.mount()
272+
await comp.async_handle_event("update", {"value": 99})
273+
assert comp.state["value"] == 99
274+
275+
@pytest.mark.asyncio
276+
async def test_async_handle_event_works_with_sync_handler(self):
277+
Component.renderer = MockRenderer()
278+
comp = AsyncSampleComponent()
279+
comp.mount()
280+
await comp.async_handle_event("sync_event", {"value": 42})
281+
assert comp.state["value"] == 42
282+
283+
@pytest.mark.asyncio
284+
async def test_async_handle_event_missing_handler_raises(self):
285+
comp = AsyncSampleComponent()
286+
with pytest.raises(EventNotFoundError, match="No handler for event: nonexistent"):
287+
await comp.async_handle_event("nonexistent", {})
288+
289+
@pytest.mark.asyncio
290+
async def test_async_handle_event_invalid_payload_raises(self):
291+
comp = AsyncSampleComponent()
292+
comp.mount()
293+
with pytest.raises(ComponentError, match="Invalid payload for update"):
294+
await comp.async_handle_event("update", {"wrong_param": 1})
295+
296+
@pytest.mark.asyncio
297+
async def test_async_handle_event_handler_exception_wrapped(self):
298+
comp = AsyncSampleComponent()
299+
comp.mount()
300+
with pytest.raises(ComponentError, match="Error handling failing_event"):
301+
await comp.async_handle_event("failing_event", {})
302+
303+
def test_sync_handle_event_rejects_async_handler(self):
304+
comp = AsyncSampleComponent()
305+
comp.mount()
306+
with pytest.raises(ComponentError, match="is async"):
307+
comp.handle_event("update", {"value": 1})
308+
309+
310+
# ---------- Async Dispatch ----------
311+
312+
313+
class TestAsyncDispatch:
314+
def setup_method(self):
315+
Component.renderer = MockRenderer()
316+
317+
@pytest.mark.asyncio
318+
async def test_async_dispatch_mount_path(self):
319+
comp = AsyncSampleComponent(initial=7)
320+
result = await comp.async_dispatch()
321+
assert result["state"]["value"] == 7
322+
assert result["state"]["doubled"] == 14
323+
assert "html" in result
324+
assert "component_id" in result
325+
326+
@pytest.mark.asyncio
327+
async def test_async_dispatch_hydrate_path(self):
328+
comp = AsyncSampleComponent()
329+
result = await comp.async_dispatch(state={"value": 20})
330+
assert result["state"]["value"] == 20
331+
332+
@pytest.mark.asyncio
333+
async def test_async_dispatch_with_async_event(self):
334+
comp = AsyncSampleComponent()
335+
result = await comp.async_dispatch(
336+
event="update", payload={"value": 50}, state={"value": 0}
337+
)
338+
assert result["state"]["value"] == 50
339+
340+
@pytest.mark.asyncio
341+
async def test_async_dispatch_with_sync_event(self):
342+
comp = AsyncSampleComponent()
343+
result = await comp.async_dispatch(
344+
event="sync_event", payload={"value": 77}, state={"value": 0}
345+
)
346+
assert result["state"]["value"] == 77
347+
348+
@pytest.mark.asyncio
349+
async def test_async_dispatch_event_error_propagates(self):
350+
comp = AsyncSampleComponent()
351+
with pytest.raises(ComponentError):
352+
await comp.async_dispatch(event="nonexistent", state={"value": 0})

0 commit comments

Comments
 (0)