Skip to content

Commit c0f7faa

Browse files
committed
feat: add configurable throttle parameter to update() method for granular UI broadcast control
1 parent 0a360a0 commit c0f7faa

5 files changed

Lines changed: 83 additions & 17 deletions

File tree

‎.agent/skills/htag-development/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ Declare individual state variables when you only need one or are passing it as a
121121
- Use the `.text` property to quickly replace all text content of a tag: `self.my_label.text = "New Status"`. This completely clears existing children and replaces them with a single string.
122122
**Background Tasks & `update()`**:
123123
- **Automatic Reactivity**: When using `State`, mutations from background tasks (started via `asyncio.create_task`) automatically trigger UI synchronization.
124-
- **Manual Synchronization**: For non-state changes or complex manual updates, every component exposes an `.update()` method (e.g., `self.update()`) which schedules a throttled UI broadcast.
124+
- **Manual Synchronization**: For non-state changes or complex manual updates, every component exposes an `.update()` method (e.g., `self.update()`) which schedules a throttled UI broadcast. By default, updates are throttled at 50ms (configurable). You can adjust this on the fly: use `self.update(throttle=0.1)` for 100ms throttle, or `self.update(throttle=0)` to force an immediate broadcast.
125125
- **Pattern**: Use `on_mount` to start background tasks and `on_unmount` to cancel them. (This pattern is now robust against page refreshes in `WebApp`).
126126

127127
```python

‎docs/components.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class Clock(Tag.div):
6363
### Background Tasks & `update()`
6464

6565
- **Automatic Reactivity**: `State` mutations from background tasks (started via `asyncio.create_task`) now automatically trigger UI updates.
66-
- **Manual Synchronization**: For non-state changes, use `self.update()` to schedule a throttled UI sync.
66+
- **Manual Synchronization**: For non-state changes, use `self.update()` to schedule a throttled UI sync. By default, it is throttled at 50ms. You can adjust it on the fly (e.g. `self.update(throttle=0.1)` for a 100ms delay, or `self.update(throttle=0)` to force an immediate broadcast).
6767

6868
```python
6969
def on_mount(self):

‎htag/core.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -800,14 +800,14 @@ def call_js(self, script: str) -> "GTag":
800800
self.__js_calls.append(script)
801801
return self
802802

803-
def update(self) -> None:
803+
def update(self, throttle: float | None = None) -> None:
804804
"""
805805
Schedules a UI synchronization.
806806
Useful when modifying state from background tasks or outside regular event handlers.
807807
"""
808808
root = self.root
809809
if root and hasattr(root, "update"):
810-
root.update()
810+
root.update(throttle=throttle)
811811

812812
# --- Public API for server-side access (avoids name-mangled access) ---
813813

‎htag/runner.py‎

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
146146
self.websockets: set[WebSocket] = set()
147147
self.sse_queues: set[asyncio.Queue] = set() # Queues for active SSE connections
148148
self.sent_statics: set[str] = set() # Track assets already in browser
149+
self.throttle_delay: float = 0.05
150+
self._last_broadcast_time: float = 0.0
149151

150152
@property
151153
def app(self) -> Any:
@@ -566,24 +568,48 @@ async def _consume_generator(self, gen: Any, ws: WebSocket | None = None) -> Non
566568
if not has_sent:
567569
self._push_to_fallback(err_payload)
568570

569-
def update(self) -> None:
571+
def update(self, throttle: float | None = None) -> None:
570572
"""
571-
Schedules a broadcast of all pending updates in the next event loop iteration.
572-
This provides a throttled, asynchronous update mechanism that can be safely
573-
triggered from both synchronous and asynchronous code.
573+
Schedules a broadcast of all pending updates after a throttle delay.
574574
"""
575-
if not getattr(self, "_update_scheduled", False):
576-
try:
577-
loop = asyncio.get_running_loop()
578-
self._update_scheduled = True
579-
loop.call_soon(self._do_update)
580-
except RuntimeError:
581-
# No running loop (might happen during early initialization)
582-
pass
575+
try:
576+
loop = asyncio.get_running_loop()
577+
now = loop.time()
578+
elapsed = now - getattr(self, "_last_broadcast_time", 0.0)
579+
throttle_delay = throttle if throttle is not None else getattr(self, "throttle_delay", 0.05)
580+
581+
target_delay = 0.0 if elapsed >= throttle_delay else (throttle_delay - elapsed)
582+
583+
if getattr(self, "_update_scheduled", False):
584+
scheduled_at = getattr(self, "_update_scheduled_at", 0.0)
585+
remaining = scheduled_at - now
586+
if target_delay < remaining:
587+
if getattr(self, "_update_handle", None) is not None:
588+
self._update_handle.cancel()
589+
else:
590+
return
591+
592+
self._update_scheduled = True
593+
self._update_scheduled_at = now + target_delay
594+
595+
if target_delay == 0.0:
596+
self._update_handle = loop.call_soon(self._do_update)
597+
else:
598+
self._update_handle = loop.call_later(target_delay, self._do_update)
599+
except RuntimeError:
600+
# No running loop
601+
pass
583602

584603
def _do_update(self) -> None:
585-
"""Internal callback to handle the actual broadcast after call_soon."""
604+
"""Internal callback to handle the actual broadcast after call_soon/call_later."""
586605
self._update_scheduled = False
606+
self._update_handle = None
607+
try:
608+
loop = asyncio.get_running_loop()
609+
self._last_broadcast_time = loop.time()
610+
except RuntimeError:
611+
import time
612+
self._last_broadcast_time = time.time()
587613
asyncio.create_task(self.broadcast_updates())
588614

589615
async def broadcast_updates(

‎tests/test_reactivity_edge_cases.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,43 @@ def test_eval_child_non_stringify():
177177
assert val is True
178178
val_str = t._eval_child(True, stringify=True)
179179
assert val_str == "True"
180+
181+
182+
@pytest.mark.asyncio
183+
async def test_update_throttle():
184+
"""Test that Runner.update() properly throttles high-frequency updates"""
185+
import time
186+
class MyApp(App):
187+
def init(self):
188+
self.val = 0
189+
190+
app = MyApp()
191+
broadcasts = []
192+
193+
async def mock_broadcast(*args, **kwargs):
194+
broadcasts.append(asyncio.get_running_loop().time())
195+
196+
app.broadcast_updates = mock_broadcast
197+
app.throttle_delay = 0.1 # 100ms throttle
198+
199+
# 1. Trigger multiple updates in a row
200+
app.update()
201+
app.update()
202+
app.update()
203+
204+
# Give the event loop a tick to run call_soon
205+
await asyncio.sleep(0.01)
206+
assert len(broadcasts) == 1
207+
208+
# 2. Trigger updates while throttled
209+
app.update() # should be scheduled for +100ms
210+
app.update()
211+
212+
await asyncio.sleep(0.05)
213+
# Still only 1 broadcast (since we are at 50ms and delay is 100ms)
214+
assert len(broadcasts) == 1
215+
216+
await asyncio.sleep(0.06)
217+
# Now we reached 110ms, the throttled update should have executed
218+
assert len(broadcasts) == 2
219+

0 commit comments

Comments
 (0)