Implement throttle and debounce as event actions (#3091)

This commit is contained in:
Masen Furer 2024-04-26 17:28:51 -07:00 committed by GitHub
parent c2017b295e
commit 3564df7620
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 164 additions and 5 deletions

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import time
from typing import Callable, Coroutine, Generator
import pytest
@ -25,6 +26,12 @@ def TestEventAction():
def on_click2(self):
self.order.append("on_click2")
def on_click_throttle(self):
self.order.append("on_click_throttle")
def on_click_debounce(self):
self.order.append("on_click_debounce")
class EventFiringComponent(rx.Component):
"""A component that fires onClick event without passing DOM event."""
@ -124,6 +131,20 @@ def TestEventAction():
"custom-prevent-default"
).prevent_default,
),
rx.button(
"Throttle",
id="btn-throttle",
on_click=lambda: EventActionState.on_click_throttle.throttle(
200
).stop_propagation,
),
rx.button(
"Debounce",
id="btn-debounce",
on_click=EventActionState.on_click_debounce.debounce(
200
).stop_propagation,
),
rx.chakra.list(
rx.foreach(
EventActionState.order, # type: ignore
@ -280,3 +301,36 @@ async def test_event_actions(
assert driver.current_url != prev_url
else:
assert driver.current_url == prev_url
@pytest.mark.usefixtures("token")
@pytest.mark.asyncio
async def test_event_actions_throttle_debounce(
driver: WebDriver,
poll_for_order: Callable[[list[str]], Coroutine[None, None, None]],
):
"""Click buttons with debounce and throttle and assert on fired events.
Args:
driver: WebDriver instance.
poll_for_order: function that polls for the order list to match the expected order.
"""
btn_throttle = driver.find_element(By.ID, "btn-throttle")
assert btn_throttle
btn_debounce = driver.find_element(By.ID, "btn-debounce")
assert btn_debounce
exp_events = 10
throttle_duration = exp_events * 0.2 # 200ms throttle
throttle_start = time.time()
while time.time() - throttle_start < throttle_duration:
btn_throttle.click()
btn_debounce.click()
try:
await poll_for_order(["on_click_throttle"] * exp_events + ["on_click_debounce"])
except AssertionError:
# Sometimes the last event gets throttled due to race, this is okay.
await poll_for_order(
["on_click_throttle"] * (exp_events - 1) + ["on_click_debounce"]
)

View File

@ -0,0 +1,17 @@
const debounce_timeout_id = {};
/**
* Generic debounce helper
*
* @param {string} name - the name of the event to debounce
* @param {function} func - the function to call after debouncing
* @param {number} delay - the time in milliseconds to wait before calling the function
*/
export default function debounce(name, func, delay) {
const key = `${name}__${delay}`;
clearTimeout(debounce_timeout_id[key]);
debounce_timeout_id[key] = setTimeout(() => {
func();
delete debounce_timeout_id[key];
}, delay);
}

View File

@ -0,0 +1,22 @@
const in_throttle = {};
/**
* Generic throttle helper
*
* @param {string} name - the name of the event to throttle
* @param {number} limit - time in milliseconds between events
* @returns true if the event is allowed to execute, false if it is throttled
*/
export default function throttle(name, limit) {
const key = `${name}__${limit}`;
if (!in_throttle[key]) {
in_throttle[key] = true;
setTimeout(() => {
delete in_throttle[key];
}, limit);
// function was not throttled, so allow execution
return true;
}
return false;
}

View File

@ -12,6 +12,8 @@ import {
onLoadInternalEvent,
state_name,
} from "utils/context.js";
import debounce from "/utils/helpers/debounce";
import throttle from "/utils/helpers/throttle";
// Endpoint URLs.
const EVENTURL = env.EVENT;
@ -571,7 +573,23 @@ export const useEventLoop = (
if (event_actions?.stopPropagation && _e?.stopPropagation) {
_e.stopPropagation();
}
queueEvents(events, socket);
const combined_name = events.map((e) => e.name).join("+++");
if (event_actions?.throttle) {
// If throttle returns false, the events are not added to the queue.
if (!throttle(combined_name, event_actions.throttle)) {
return;
}
}
if (event_actions?.debounce) {
// If debounce is used, queue the events after some delay
debounce(
combined_name,
() => queueEvents(events, socket),
event_actions.debounce,
);
} else {
queueEvents(events, socket);
}
};
const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode

View File

@ -80,7 +80,7 @@ class EventActionsMixin(Base):
"""Mixin for DOM event actions."""
# Whether to `preventDefault` or `stopPropagation` on the event.
event_actions: Dict[str, bool] = {}
event_actions: Dict[str, Union[bool, int]] = {}
@property
def stop_propagation(self):
@ -104,6 +104,32 @@ class EventActionsMixin(Base):
update={"event_actions": {"preventDefault": True, **self.event_actions}},
)
def throttle(self, limit_ms: int):
"""Throttle the event handler.
Args:
limit_ms: The time in milliseconds to throttle the event handler.
Returns:
New EventHandler-like with throttle set to limit_ms.
"""
return self.copy(
update={"event_actions": {"throttle": limit_ms, **self.event_actions}},
)
def debounce(self, delay_ms: int):
"""Debounce the event handler.
Args:
delay_ms: The time in milliseconds to debounce the event handler.
Returns:
New EventHandler-like with debounce set to delay_ms.
"""
return self.copy(
update={"event_actions": {"debounce": delay_ms, **self.event_actions}},
)
class EventHandler(EventActionsMixin):
"""An event handler responds to an event to update the state."""

View File

@ -283,19 +283,41 @@ def test_event_actions():
"stopPropagation": True,
"preventDefault": True,
}
throttle_handler = handler.throttle(300)
assert handler is not throttle_handler
assert throttle_handler.event_actions == {"throttle": 300}
debounce_handler = handler.debounce(300)
assert handler is not debounce_handler
assert debounce_handler.event_actions == {"debounce": 300}
all_handler = handler.stop_propagation.prevent_default.throttle(200).debounce(100)
assert handler is not all_handler
assert all_handler.event_actions == {
"stopPropagation": True,
"preventDefault": True,
"throttle": 200,
"debounce": 100,
}
assert not handler.event_actions
# Convert to EventSpec should carry event actions
sp_handler2 = handler.stop_propagation
sp_handler2 = handler.stop_propagation.throttle(200)
spec = sp_handler2()
assert spec.event_actions == {"stopPropagation": True}
assert spec.event_actions == {"stopPropagation": True, "throttle": 200}
assert spec.event_actions == sp_handler2.event_actions
assert spec.event_actions is not sp_handler2.event_actions
# But it should be a copy!
assert spec.event_actions is not sp_handler2.event_actions
spec2 = spec.prevent_default
assert spec is not spec2
assert spec2.event_actions == {"stopPropagation": True, "preventDefault": True}
assert spec2.event_actions == {
"stopPropagation": True,
"preventDefault": True,
"throttle": 200,
}
assert spec2.event_actions != spec.event_actions
# The original handler should still not be touched.