Skip to content

Commit abbb27f

Browse files
committed
debounce user inputs to prevent character loss
1 parent 773bf7e commit abbb27f

File tree

2 files changed

+54
-2
lines changed

2 files changed

+54
-2
lines changed

src/js/packages/@reactpy/client/src/components.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,32 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
8282
const client = useContext(ClientContext);
8383
const props = createAttributes(model, client);
8484
const [value, setValue] = useState(props.value);
85+
const lastUserValue = useRef(props.value);
86+
const lastChangeTime = useRef(0);
8587

8688
// honor changes to value from the client via props
87-
useEffect(() => setValue(props.value), [props.value]);
89+
useEffect(() => {
90+
// If the new prop value matches what we last sent, we are in sync.
91+
// If it differs, we only update if sufficient time has passed since user input,
92+
// effectively debouncing server overrides during rapid typing.
93+
const now = Date.now();
94+
if (
95+
props.value === lastUserValue.current ||
96+
now - lastChangeTime.current > 200
97+
) {
98+
setValue(props.value);
99+
}
100+
}, [props.value]);
88101

89102
const givenOnChange = props.onChange;
90103
if (typeof givenOnChange === "function") {
91104
props.onChange = (event: TargetedEvent<any>) => {
92105
// immediately update the value to give the user feedback
93106
if (event.target) {
94-
setValue((event.target as HTMLInputElement).value);
107+
const newValue = (event.target as HTMLInputElement).value;
108+
setValue(newValue);
109+
lastUserValue.current = newValue;
110+
lastChangeTime.current = Date.now();
95111
}
96112
// allow the client to respond (and possibly change the value)
97113
givenOnChange(event);

tests/test_core/test_events.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,3 +586,39 @@ async def add_top(event):
586586
await btn_b.click() # This generates event for .../1
587587

588588
assert clicked_items == ["B"]
589+
590+
591+
async def test_controlled_input_typing(display: DisplayFixture):
592+
"""
593+
Test that a controlled input updates correctly even with rapid typing.
594+
This validates that event queueing/processing order is maintained.
595+
"""
596+
597+
@reactpy.component
598+
def ControlledInput():
599+
value, set_value = use_state("")
600+
601+
def on_change(event):
602+
set_value(event["target"]["value"])
603+
604+
return reactpy.html.input(
605+
{
606+
"value": value,
607+
"onChange": on_change,
608+
"id": "controlled-input",
609+
}
610+
)
611+
612+
await display.show(ControlledInput)
613+
614+
inp = await display.page.wait_for_selector("#controlled-input")
615+
616+
# Type a long string rapidly
617+
target_text = "hello world this is a test"
618+
await inp.type(target_text, delay=0)
619+
620+
# Wait a bit for all events to settle
621+
await asyncio.sleep(0.5)
622+
623+
# Check the final value
624+
assert (await inp.evaluate("node => node.value")) == target_text

0 commit comments

Comments
 (0)