Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement throttle and debounce as event actions #3091

Merged
merged 3 commits into from
Apr 27, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions integration/test_event_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import asyncio
import time
from typing import Callable, Coroutine, Generator

import pytest
Expand All @@ -25,6 +26,12 @@ def on_click(self, ev):
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."""

Expand Down Expand Up @@ -124,6 +131,20 @@ def index():
"custom-prevent-default"
).prevent_default,
),
rx.button(
"Throttle",
id="btn-throttle",
on_click=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
Expand Down Expand Up @@ -280,3 +301,32 @@ 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()

await poll_for_order(
["on_click_throttle"] * int(exp_events) + ["on_click_debounce"]
)
17 changes: 17 additions & 0 deletions reflex/.templates/web/utils/helpers/debounce.js
Original file line number Diff line number Diff line change
@@ -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 changes: 22 additions & 0 deletions reflex/.templates/web/utils/helpers/throttle.js
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 19 additions & 1 deletion reflex/.templates/web/utils/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion reflex/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -104,6 +104,32 @@ def prevent_default(self):
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."""
Expand Down
28 changes: 25 additions & 3 deletions tests/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading