diff --git a/CHANGELOG.md b/CHANGELOG.md index b81e2e6d..a2154f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +* State-kind reactive accessors (e.g., `map.bounds()`, `plot.camera()`, `grid.view_config()`) now attach to the widget at render time instead of on first read. An interaction made before the accessor's first read -- for example, a pan or selection made before a chat handler or LLM tool first reads the accessor -- is now reflected in that first read instead of being lost. Event-kind accessors (e.g., plotly's `selected()`/`clicked()`) still attach on first read; if their first reader runs late, read them once at startup in a `@reactive.effect` to attach immediately. * shinywidgets now requires ipywidgets 8.1 or newer, and the bundled JavaScript widget manager (`@jupyter-widgets/html-manager`) was upgraded from 0.20 to 1.0. Together these fix a wide range of long-standing issues with ipywidgets 8 (see the bug fixes below). (#237, #259) * shinywidgets now requires plotly >= 5.12 (the first release whose `FigureWidget` frontend supports ipywidgets 8). Constructing a plotly widget with an older plotly raises an error explaining how to upgrade, instead of rendering a blank output with no error. (#332) * shinywidgets now requires shiny 1.9.0 or newer, which sends widget updates made outside the reactive graph (e.g., from a background thread) to the browser right away. (#377) diff --git a/shinywidgets/_accessors.py b/shinywidgets/_accessors.py index 10cad86f..53b1be10 100644 --- a/shinywidgets/_accessors.py +++ b/shinywidgets/_accessors.py @@ -60,6 +60,13 @@ class AccessorSpec: eager Attach during render rather than on first read, for widgets whose events are lost unless a handler exists before the view is created. + The default is True for ``kind="state"`` (attaching seeds the current + value, so a change made before the first read -- e.g. a lasso drawn + before a chat handler's first read -- is not lost) and False for + ``kind="event"``. An event-kind accessor stays lazy unless it opts + in, so an app whose first reader is gated (a chat handler, an LLM + tool) should warm it up with a ``@reactive.effect`` that reads the + accessor once, unconditionally, at startup. not_ready True for raw values that mean "the widget hasn't reported yet"; reads then cancel (`req(False)`) instead of returning a value. @@ -72,10 +79,14 @@ class AccessorSpec: priority: Literal["input", "event"] = "input" rate_policy: Optional[RatePolicy] = None rate_delay: float = 0.25 - eager: bool = False + # None resolves to kind == "state" in __post_init__ (a frozen dataclass + # can't default one field from another) + eager: Optional[bool] = None not_ready: Callable[[Any], bool] = never_ready_check def __post_init__(self) -> None: + if self.eager is None: + object.__setattr__(self, "eager", self.kind == "state") if self.name == "value": raise ValueError( "An accessor can't be named `value`: renderers already use it for the rendered object." diff --git a/tests/unit/test_accessors.py b/tests/unit/test_accessors.py index dd7c3037..481140ed 100644 --- a/tests/unit/test_accessors.py +++ b/tests/unit/test_accessors.py @@ -238,6 +238,39 @@ def test_eager_attach_in_on_render() -> None: assert cap.attached == 1 +def test_eager_defaults_to_kind() -> None: + # State accessors attach at render (a change before the first read -- a + # lasso drawn before a chat handler's first read -- is then not lost); + # event accessors stay lazy + state = acc.AccessorSpec(name="zoom", kind="state", attach=noop_attach) + event = acc.AccessorSpec(name="clicked", kind="event", attach=noop_attach) + assert state.eager is True and event.eager is False + + +def test_state_attach_is_eager_by_default() -> None: + cap = Capture() + spec = acc.AccessorSpec(name="zoom", kind="state", attach=cap.attach) + a = acc.Accessors([spec], schedule=FakeSchedule()) + a.on_render(object()) + assert cap.attached == 1 # attached at render, with no read + + +def test_event_attach_stays_lazy_by_default() -> None: + a, cap, _ = make() # make() builds a kind="event" spec + a.on_render(object()) + assert cap.attached == 0 + + +def test_eager_false_keeps_a_state_accessor_lazy() -> None: + cap = Capture() + spec = acc.AccessorSpec(name="zoom", kind="state", attach=cap.attach, eager=False) + a = acc.Accessors([spec], schedule=FakeSchedule()) + a.on_render(object()) + assert cap.attached == 0 + a.read("zoom") + assert cap.attached == 1 + + def test_event_publishes_and_resets_on_render() -> None: a, cap, _ = make() a.on_render(object())