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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from typing import Callable, Coroutine, Generator
|
from typing import Callable, Coroutine, Generator
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -25,6 +26,12 @@ def TestEventAction():
|
|||||||
def on_click2(self):
|
def on_click2(self):
|
||||||
self.order.append("on_click2")
|
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):
|
class EventFiringComponent(rx.Component):
|
||||||
"""A component that fires onClick event without passing DOM event."""
|
"""A component that fires onClick event without passing DOM event."""
|
||||||
|
|
||||||
@ -124,6 +131,20 @@ def TestEventAction():
|
|||||||
"custom-prevent-default"
|
"custom-prevent-default"
|
||||||
).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.chakra.list(
|
||||||
rx.foreach(
|
rx.foreach(
|
||||||
EventActionState.order, # type: ignore
|
EventActionState.order, # type: ignore
|
||||||
@ -280,3 +301,36 @@ async def test_event_actions(
|
|||||||
assert driver.current_url != prev_url
|
assert driver.current_url != prev_url
|
||||||
else:
|
else:
|
||||||
assert driver.current_url == prev_url
|
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,
|
onLoadInternalEvent,
|
||||||
state_name,
|
state_name,
|
||||||
} from "utils/context.js";
|
} from "utils/context.js";
|
||||||
|
import debounce from "/utils/helpers/debounce";
|
||||||
|
import throttle from "/utils/helpers/throttle";
|
||||||
|
|
||||||
// Endpoint URLs.
|
// Endpoint URLs.
|
||||||
const EVENTURL = env.EVENT;
|
const EVENTURL = env.EVENT;
|
||||||
@ -571,7 +573,23 @@ export const useEventLoop = (
|
|||||||
if (event_actions?.stopPropagation && _e?.stopPropagation) {
|
if (event_actions?.stopPropagation && _e?.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
|
const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode
|
||||||
|
@ -80,7 +80,7 @@ class EventActionsMixin(Base):
|
|||||||
"""Mixin for DOM event actions."""
|
"""Mixin for DOM event actions."""
|
||||||
|
|
||||||
# Whether to `preventDefault` or `stopPropagation` on the event.
|
# Whether to `preventDefault` or `stopPropagation` on the event.
|
||||||
event_actions: Dict[str, bool] = {}
|
event_actions: Dict[str, Union[bool, int]] = {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def stop_propagation(self):
|
def stop_propagation(self):
|
||||||
@ -104,6 +104,32 @@ class EventActionsMixin(Base):
|
|||||||
update={"event_actions": {"preventDefault": True, **self.event_actions}},
|
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):
|
class EventHandler(EventActionsMixin):
|
||||||
"""An event handler responds to an event to update the state."""
|
"""An event handler responds to an event to update the state."""
|
||||||
|
@ -283,19 +283,41 @@ def test_event_actions():
|
|||||||
"stopPropagation": True,
|
"stopPropagation": True,
|
||||||
"preventDefault": 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
|
assert not handler.event_actions
|
||||||
|
|
||||||
# Convert to EventSpec should carry event actions
|
# Convert to EventSpec should carry event actions
|
||||||
sp_handler2 = handler.stop_propagation
|
sp_handler2 = handler.stop_propagation.throttle(200)
|
||||||
spec = sp_handler2()
|
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 == sp_handler2.event_actions
|
||||||
assert spec.event_actions is not sp_handler2.event_actions
|
assert spec.event_actions is not sp_handler2.event_actions
|
||||||
# But it should be a copy!
|
# But it should be a copy!
|
||||||
assert spec.event_actions is not sp_handler2.event_actions
|
assert spec.event_actions is not sp_handler2.event_actions
|
||||||
spec2 = spec.prevent_default
|
spec2 = spec.prevent_default
|
||||||
assert spec is not spec2
|
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
|
assert spec2.event_actions != spec.event_actions
|
||||||
|
|
||||||
# The original handler should still not be touched.
|
# The original handler should still not be touched.
|
||||||
|
Loading…
Reference in New Issue
Block a user