Skip to content

Commit bec9cf8

Browse files
evnchnclaude
andcommitted
Skip echoing client-originated values via bool flag on ValueElement
Add _value_from_client flag to ValueElement, set when LOOPBACK handles client input. The outbox strips VALUE_PROP from updates when the flag is True, preventing the server from echoing the value back. JS merges incoming props with existing state so the omitted prop is preserved. _to_dict() always returns full state for reconnection safety. The flag resets on server-originated value changes (bindings, sanitize). Implements the approach proposed by @falkoschindler in #5728. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 498e87c commit bec9cf8

4 files changed

Lines changed: 90 additions & 1 deletion

File tree

nicegui/elements/mixins/value_element.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def __init__(self, *,
3131
) -> None:
3232
super().__init__(**kwargs)
3333
self._send_update_on_value_change = True
34+
self._value_from_client: bool = False
3435
self.set_value(value)
3536
self._props[self.VALUE_PROP] = self._value_to_model_value(value)
3637
self._props['loopback'] = self.LOOPBACK
@@ -39,6 +40,7 @@ def __init__(self, *,
3940
def handle_change(e: GenericEventArguments) -> None:
4041
self._send_update_on_value_change = self.LOOPBACK is True
4142
self.set_value(self._event_args_to_value(e))
43+
self._value_from_client = self.LOOPBACK is True
4244
self._send_update_on_value_change = True
4345
self.on(f'update:{self.VALUE_PROP}', handle_change, [None], throttle=throttle)
4446

@@ -124,6 +126,7 @@ def _handle_value_change(self, value: Any) -> None:
124126
with self._props.suspend_updates():
125127
self._props[self.VALUE_PROP] = self._value_to_model_value(value)
126128
if self._send_update_on_value_change:
129+
self._value_from_client = False
127130
self.update()
128131
args = ValueChangeEventArguments(sender=self, client=self.client,
129132
value=self._value_to_event_value(value),

nicegui/outbox.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ def enqueue_message(self, message_type: MessageType, data: Payload, target_id: C
8181
self.messages.append((target_id, message_type, data))
8282
self._set_enqueue_event()
8383

84+
@staticmethod
85+
def _build_element_dict(element: Element) -> dict[str, Any]:
86+
"""Build the update dict for an element, omitting VALUE_PROP if the value came from the client."""
87+
element_dict = element._to_dict() # pylint: disable=protected-access
88+
if getattr(element, '_value_from_client', False):
89+
element_dict.get('props', {}).pop(getattr(element, 'VALUE_PROP', None), None) # type: ignore[arg-type]
90+
element._value_from_client = False # type: ignore[attr-defined] # pylint: disable=protected-access
91+
return element_dict
92+
8493
async def loop(self) -> None:
8594
"""Send updates and messages to all clients in an endless loop."""
8695
self._enqueue_event = asyncio.Event()
@@ -104,7 +113,7 @@ async def loop(self) -> None:
104113
coros = []
105114
if self.updates:
106115
data = {
107-
element_id: None if element is deleted else element._to_dict() # type: ignore # pylint: disable=protected-access
116+
element_id: None if element is deleted else self._build_element_dict(element) # type: ignore[arg-type]
108117
for element_id, element in self.updates.items()
109118
}
110119
js_components = [

nicegui/static/nicegui.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,10 @@ function createApp(elements, options) {
500500
delete this.elements[id];
501501
continue;
502502
}
503+
const existing = this.elements[id];
504+
if (existing && element.props) {
505+
element.props = { ...existing.props, ...element.props };
506+
}
503507
replaceUndefinedAttributes(element);
504508
this.elements[id] = element;
505509
}

tests/test_value_from_client.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from nicegui import ui
2+
from nicegui.testing import Screen
3+
4+
5+
def test_server_set_value_includes_value_in_update(screen: Screen):
6+
@ui.page('/')
7+
def page():
8+
inp = ui.input(value='initial')
9+
# Server-side change: flag should be False
10+
inp.value = 'from_server'
11+
assert inp._value_from_client is False
12+
# _to_dict always includes VALUE_PROP
13+
d = inp._to_dict()
14+
assert 'props' in d
15+
assert inp.VALUE_PROP in d['props']
16+
assert d['props'][inp.VALUE_PROP] == 'from_server'
17+
ui.label('server_test_passed')
18+
19+
screen.open('/')
20+
screen.should_contain('server_test_passed')
21+
22+
23+
def test_flag_set_true_when_simulating_client_change(screen: Screen):
24+
@ui.page('/')
25+
def page():
26+
inp = ui.input(value='initial')
27+
inp._value_from_client = True
28+
d = inp._to_dict()
29+
assert inp.VALUE_PROP in d['props']
30+
ui.label('flag_test_passed')
31+
32+
screen.open('/')
33+
screen.should_contain('flag_test_passed')
34+
35+
36+
def test_to_dict_always_includes_value(screen: Screen):
37+
@ui.page('/')
38+
def page():
39+
inp = ui.input(value='hello')
40+
inp._value_from_client = True
41+
d = inp._to_dict()
42+
assert inp.VALUE_PROP in d['props']
43+
assert d['props'][inp.VALUE_PROP] == 'hello'
44+
ui.label('reconnect_safe_passed')
45+
46+
screen.open('/')
47+
screen.should_contain('reconnect_safe_passed')
48+
49+
50+
def test_flag_false_by_default(screen: Screen):
51+
@ui.page('/')
52+
def page():
53+
inp = ui.input(value='test')
54+
assert inp._value_from_client is False
55+
select = ui.select(options=['a', 'b'], value='a')
56+
assert select._value_from_client is False
57+
ui.label('default_flag_passed')
58+
59+
screen.open('/')
60+
screen.should_contain('default_flag_passed')
61+
62+
63+
def test_loopback_true_server_change_keeps_flag_false(screen: Screen):
64+
@ui.page('/')
65+
def page():
66+
select = ui.select(options=['a', 'b', 'c'], value='a')
67+
assert select.LOOPBACK is True
68+
select.value = 'b'
69+
assert select._value_from_client is False
70+
ui.label('loopback_test_passed')
71+
72+
screen.open('/')
73+
screen.should_contain('loopback_test_passed')

0 commit comments

Comments
 (0)