From 3564df7620ad90d323672eff4ac31261499719a2 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 26 Apr 2024 17:28:51 -0700 Subject: [PATCH] Implement `throttle` and `debounce` as event actions (#3091) --- integration/test_event_actions.py | 54 +++++++++++++++++++ .../.templates/web/utils/helpers/debounce.js | 17 ++++++ .../.templates/web/utils/helpers/throttle.js | 22 ++++++++ reflex/.templates/web/utils/state.js | 20 ++++++- reflex/event.py | 28 +++++++++- tests/test_event.py | 28 ++++++++-- 6 files changed, 164 insertions(+), 5 deletions(-) create mode 100644 reflex/.templates/web/utils/helpers/debounce.js create mode 100644 reflex/.templates/web/utils/helpers/throttle.js diff --git a/integration/test_event_actions.py b/integration/test_event_actions.py index e9268904d..3991704b8 100644 --- a/integration/test_event_actions.py +++ b/integration/test_event_actions.py @@ -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"] + ) diff --git a/reflex/.templates/web/utils/helpers/debounce.js b/reflex/.templates/web/utils/helpers/debounce.js new file mode 100644 index 000000000..465baae1c --- /dev/null +++ b/reflex/.templates/web/utils/helpers/debounce.js @@ -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); +} diff --git a/reflex/.templates/web/utils/helpers/throttle.js b/reflex/.templates/web/utils/helpers/throttle.js new file mode 100644 index 000000000..771937ba3 --- /dev/null +++ b/reflex/.templates/web/utils/helpers/throttle.js @@ -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; +} diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 9736e4500..5bc6b8b8b 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -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 diff --git a/reflex/event.py b/reflex/event.py index 057355c98..2604cc202 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -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.""" diff --git a/tests/test_event.py b/tests/test_event.py index 2326f0920..5915baf12 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -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.