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

Avoid events boomeranging from frontend #7093

Merged
merged 7 commits into from
Aug 7, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
55 changes: 45 additions & 10 deletions panel/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ def __init__(self, **params):
# A dictionary of bokeh property changes being processed
self._changing = {}

# A dictionary of parameter changes being processed
self._in_process__events = {}

# Whether the component is watching the stylesheets
self._watching_stylesheets = False

Expand Down Expand Up @@ -304,29 +307,57 @@ def _update_manual(self, *events: param.parameterized.Event) -> None:
else:
cb()

def _scheduled_update_model(
self, events: dict[str, param.parameterized.Event], msg: dict[str, Any],
root: Model, model: Model, doc: Document, comm: Optional[Comm],
curdoc_events: dict[str, Any]
) -> None:
#
self._in_process__events[doc] = curdoc_events
try:
self._update_model(events, msg, root, model, doc, comm)
finally:
del self._in_process__events[doc]

def _apply_update(
self, events: dict[str, param.parameterized.Event], msg: dict[str, Any],
model: Model, ref: str
) -> None:
) -> bool:
if ref not in state._views or ref in state._fake_roots:
return
return True
viewable, root, doc, comm = state._views[ref]
if comm or not doc.session_context or state._unblocked(doc):
with unlocked():
self._update_model(events, msg, root, model, doc, comm)
if comm and 'embedded' not in root.tags:
push(doc, comm)
return True
else:
cb = partial(self._update_model, events, msg, root, model, doc, comm)
curdoc_events = self._in_process__events.pop(doc, {})
cb = partial(self._scheduled_update_model, events, msg, root, model, doc, comm, curdoc_events)
doc.add_next_tick_callback(cb)
return False

def _update_model(
self, events: dict[str, param.parameterized.Event], msg: dict[str, Any],
root: Model, model: Model, doc: Document, comm: Optional[Comm]
) -> None:
ref = root.ref['id']
self._changing[ref] = attrs = []
for attr, value in msg.items():
curdoc_events = self._in_process__events.get(doc, {})
for attr, value in msg.copy().items():
if attr in curdoc_events and value is curdoc_events[attr]:
# Do not apply change that originated directly from
# the frontend since this may cause boomerang if a
# new property value is already in-flight
del msg[attr]
continue
elif attr in self._events:
# Do not override a property value that was just changed
# on the frontend
del self._events[attr]
continue

# Bokeh raises UnsetValueError if the value is Undefined.
try:
model_val = getattr(model, attr)
Expand All @@ -336,10 +367,6 @@ def _update_model(
if not model.lookup(attr).property.matches(model_val, value):
attrs.append(attr)

# Do not apply model change that is in flight
if attr in self._events:
del self._events[attr]

try:
model.update(**msg)
finally:
Expand Down Expand Up @@ -405,18 +432,26 @@ async def _watch_stylesheets(self):

def _param_change(self, *events: param.parameterized.Event) -> None:
named_events = {event.name: event for event in events}
applied = False
for ref, (model, _) in self._models.copy().items():
properties = self._update_properties(*events, doc=model.document)
if not properties:
return
self._apply_update(named_events, properties, model, ref)
applied &= self._apply_update(named_events, properties, model, ref)
if ref not in state._views:
continue
doc = state._views[ref][2]
if applied and doc in self._in_process__events:
del self._in_process__events[doc]

def _process_events(self, events: dict[str, Any]) -> None:
self._log('received events %s', events)
if any(e for e in events if e not in self._busy__ignore):
with edit_readonly(state):
state._busy_counter += 1
params = self._process_property_change(dict(events))
if events:
self._in_process__events[state.curdoc] = events
params = self._process_property_change(events)
try:
with edit_readonly(self):
self_params = {k: v for k, v in params.items() if '.' not in k}
Expand Down
6 changes: 3 additions & 3 deletions panel/tests/test_param.py
Original file line number Diff line number Diff line change
Expand Up @@ -574,7 +574,7 @@ class Test(param.Parameterized):
assert mb.value != 3
assert test.b == 3

test_pane._widgets['b']._process_events({'value': 4})
mb.value = 4
assert test.b == 3
assert mb.value == 4

Expand Down Expand Up @@ -616,7 +616,7 @@ class Test(param.Parameterized):
assert mb.value != '3'
assert test.b == '3'

test_pane._widgets['b']._process_events({'value': '4'})
mb.value = '4'
assert test.b == '3'
assert mb.value == '4'

Expand Down Expand Up @@ -873,7 +873,7 @@ class Test(param.Parameterized):
assert number.value != 3
assert test.a == 3

pane._widgets['a']._process_events({'value': 4})
number.value = 4
assert test.a == 3
assert number.value == 4

Expand Down
14 changes: 14 additions & 0 deletions panel/tests/test_reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from bokeh.models import Div

from panel.depends import bind, depends
from panel.io.state import set_curdoc
from panel.layout import Tabs, WidgetBox
from panel.pane import Markdown
from panel.reactive import Reactive, ReactiveHTML
Expand Down Expand Up @@ -370,6 +371,19 @@ def test_text_input_controls_explicit():
text_input.placeholder = "Test placeholder..."
assert placeholder.value == "Test placeholder..."

def test_property_change_does_not_boomerang(document, comm):
text_input = TextInput(value='A')

model = text_input.get_root(document, comm)

assert model.value == 'A'
model.value = 'B'
with set_curdoc(document):
text_input._process_events({'value': 'C'})

assert model.value == 'B'
assert text_input.value == 'C'

def test_reactive_html_basic():

class Test(ReactiveHTML):
Expand Down
Loading