Implement throttle
and debounce
as event actions (#3091)
This commit is contained in:
parent
c2017b295e
commit
3564df7620
@ -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
reflex/.templates/web/utils/helpers/debounce.js
Normal file
17
reflex/.templates/web/utils/helpers/debounce.js
Normal 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);
|
||||
}
|
22
reflex/.templates/web/utils/helpers/throttle.js
Normal file
22
reflex/.templates/web/utils/helpers/throttle.js
Normal 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;
|
||||
}
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user