Skip to content

Commit 2d0c98f

Browse files
committed
self review 2: Add client-server reconciliation, clean-up wheel script
1 parent a5f8d9c commit 2d0c98f

4 files changed

Lines changed: 120 additions & 27 deletions

File tree

src/build_scripts/build_local_wheel.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55

66
from __future__ import annotations
77

8+
import importlib.util
89
import logging
910
import os
1011
import re
1112
import shutil
1213
import subprocess
14+
import sys
1315
from pathlib import Path
1416

1517
_logger = logging.getLogger(__name__)
@@ -47,6 +49,9 @@ def _hatch_build_command(root_dir: Path) -> list[str] | None:
4749
if hatch_command := shutil.which("hatch"):
4850
return [hatch_command, "build", "-t", "wheel"]
4951

52+
if importlib.util.find_spec("hatch") is not None:
53+
return [sys.executable, "-m", "hatch", "build", "-t", "wheel"]
54+
5055
return None
5156

5257

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

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,19 +116,37 @@ function UserInputElement({ model }: { model: ReactPyVdom }): JSX.Element {
116116
const lastUserValue = useRef(props.value);
117117
const lastChangeTime = useRef(0);
118118
const lastInputDebounce = useRef(DEFAULT_INPUT_DEBOUNCE);
119+
const reconcileTimeout = useRef<number | null>(null);
119120

120121
// honor changes to value from the client via props
121122
useEffect(() => {
122-
// If the new prop value matches what we last sent, we are in sync.
123-
// If it differs, we only update if sufficient time has passed since user input,
124-
// effectively debouncing server overrides during rapid typing.
125-
const now = Date.now();
126-
if (
127-
props.value === lastUserValue.current ||
128-
now - lastChangeTime.current >= lastInputDebounce.current
129-
) {
130-
setValue(props.value);
131-
}
123+
const reconcileValue = () => {
124+
// If the new prop value matches what we last sent, we are in sync.
125+
// If it differs, wait until the debounce window expires before applying it.
126+
const elapsed = Date.now() - lastChangeTime.current;
127+
if (
128+
props.value === lastUserValue.current ||
129+
elapsed >= lastInputDebounce.current
130+
) {
131+
reconcileTimeout.current = null;
132+
setValue(props.value);
133+
return;
134+
}
135+
136+
reconcileTimeout.current = window.setTimeout(
137+
reconcileValue,
138+
Math.max(0, lastInputDebounce.current - elapsed),
139+
);
140+
};
141+
142+
reconcileValue();
143+
144+
return () => {
145+
if (reconcileTimeout.current !== null) {
146+
window.clearTimeout(reconcileTimeout.current);
147+
reconcileTimeout.current = null;
148+
}
149+
};
132150
}, [props.value]);
133151

134152
for (const [name, prop] of Object.entries(props)) {

tests/test_build_local_wheel.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
from pathlib import Path
5+
from unittest import mock
6+
7+
8+
def _load_build_local_wheel_module():
9+
module_path = (
10+
Path(__file__).resolve().parents[1]
11+
/ "src"
12+
/ "build_scripts"
13+
/ "build_local_wheel.py"
14+
)
15+
spec = importlib.util.spec_from_file_location("build_local_wheel", module_path)
16+
assert spec is not None
17+
assert spec.loader is not None
18+
19+
module = importlib.util.module_from_spec(spec)
20+
spec.loader.exec_module(module)
21+
return module
22+
23+
24+
def test_hatch_build_command_uses_python_module_when_available(tmp_path):
25+
build_local_wheel = _load_build_local_wheel_module()
26+
27+
with (
28+
mock.patch.object(build_local_wheel.shutil, "which", return_value=None),
29+
mock.patch.object(
30+
build_local_wheel.importlib.util,
31+
"find_spec",
32+
return_value=object(),
33+
),
34+
):
35+
assert build_local_wheel._hatch_build_command(tmp_path) == [
36+
build_local_wheel.sys.executable,
37+
"-m",
38+
"hatch",
39+
"build",
40+
"-t",
41+
"wheel",
42+
]

tests/test_core/test_events.py

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,9 @@ async def add_top(event):
649649
async def test_controlled_input_typing(display: DisplayFixture):
650650
"""
651651
Test that a controlled input updates correctly even with rapid typing.
652-
This validates that event queueing/processing order is maintained.
652+
This validates that user inputs are processed in the correct order and that the
653+
event queueing/processing order is consistent with user expectations, even if the
654+
server is still processing previous events.
653655
"""
654656

655657
@reactpy.component
@@ -659,12 +661,15 @@ def ControlledInput():
659661
def on_change(event):
660662
set_value(event["target"]["value"])
661663

662-
return reactpy.html.input(
663-
{
664-
"value": value,
665-
"onChange": on_change,
666-
"id": "controlled-input",
667-
}
664+
return reactpy.html.div(
665+
reactpy.html.input(
666+
{
667+
"value": value,
668+
"onChange": on_change,
669+
"id": "controlled-input",
670+
},
671+
),
672+
reactpy.html.pre({"id": "server-value"}, value),
668673
)
669674

670675
await display.show(ControlledInput)
@@ -678,9 +683,13 @@ def on_change(event):
678683
# Wait a bit for all events to settle
679684
await asyncio.sleep(0.5)
680685

681-
# Check the final value
686+
# Ensure all characters stayed within the client, even if server updates were in-flight
682687
assert (await inp.evaluate("node => node.value")) == target_text
683688

689+
# Ensure the server and client are in sync
690+
server_value = await display.page.locator("#server-value").text_content()
691+
assert server_value == target_text
692+
684693

685694
async def test_controlled_input_respects_custom_debounce(display: DisplayFixture):
686695
@reactpy.component
@@ -710,10 +719,12 @@ def on_change(event: Event):
710719
assert (await inp.evaluate("node => node.value")) == "A"
711720

712721

713-
async def test_controlled_input_default_debounce_prefers_latest_client_value(
722+
async def test_controlled_input_default_debounce_reconciles_server_value(
714723
display: DisplayFixture,
715724
):
716-
"""Prefer the latest client value for a controlled input when using debounce, even if the server is still processing an older event."""
725+
"""Verifies if the client keeps the latest user-provided input value even
726+
if it received a conflicting server update within the debounce period, then
727+
ultimately reconciles once debounce expires."""
717728

718729
@reactpy.component
719730
def ControlledInput():
@@ -722,18 +733,35 @@ def ControlledInput():
722733
def on_change(event: Event):
723734
set_value(event.target.value.upper())
724735

725-
return reactpy.html.input(
726-
{
727-
"value": value,
728-
"onChange": on_change,
729-
"id": "controlled-input",
730-
}
736+
return reactpy.html.div(
737+
reactpy.html.input(
738+
{
739+
"value": value,
740+
"onChange": on_change,
741+
"id": "controlled-input",
742+
}
743+
),
744+
reactpy.html.pre({"id": "server-value"}, value),
731745
)
732746

733747
await display.show(ControlledInput)
734748

735749
inp = await display.page.wait_for_selector("#controlled-input")
736750
await inp.type("a", delay=0)
737751

738-
await asyncio.sleep(0.5)
752+
await display.page.wait_for_function(
753+
"""
754+
() => {
755+
const input = document.getElementById('controlled-input');
756+
const serverValue = document.getElementById('server-value');
757+
return input?.value === 'a' && serverValue?.textContent === 'A';
758+
}
759+
"""
760+
)
739761
assert (await inp.evaluate("node => node.value")) == "a"
762+
assert await display.page.locator("#server-value").text_content() == "A"
763+
764+
await display.page.wait_for_function(
765+
"() => document.getElementById('controlled-input')?.value === 'A'"
766+
)
767+
assert (await inp.evaluate("node => node.value")) == "A"

0 commit comments

Comments
 (0)