Kaynağa Gözat

Implement `throttle` and `debounce` as event actions (#3091)

Masen Furer 1 yıl önce
ebeveyn
işleme
3564df7620

+ 54 - 0
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"]
+        )

+ 17 - 0
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);
+}

+ 22 - 0
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;
+}

+ 19 - 1
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

+ 27 - 1
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."""

+ 25 - 3
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.