From 44c23de2a9e0b02f0080c8fb825ef1745b02348c Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:43:42 +0500 Subject: [PATCH] fix(reflex): stream on_view_change during gestures with a latest-wins throttle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #113 replaced the wrapper's immediate on_view_change dispatch with a 200 ms trailing-edge debounce. A continuous pan/zoom emits a view_change per animation frame, so the timer reset on every frame and the handler could only fire after the gesture went quiet — an on_view_change-computed detail chart froze until the interaction ended instead of tracking it live (demo section 4). Replace the debounce with a leading+trailing latest-wins throttle (VIEW_THROTTLE_MS = 120, matching hover): the first event of a gesture dispatches immediately, updates stream at most once per window while it is in progress, and the trailing flush guarantees the resting viewport always lands last. The envelope's phase field now reflects the client's gesture phase — "update" mid-gesture, "final" at rest — so handlers that only want settled views can filter on it; linked/republish suppression and unmount cleanup are unchanged. Verified against the running showcase app with a CDP probe: 24 wheel steps at 140 ms spacing produced 23 mid-gesture Reflex state updates (the old debounce produces none), with the exact resting view arriving as the final event. Spec (reflex-integration.md), ViewChangeEvent typing/docstring, README, demo comment, and the wrapper contract test updated to pin the new behavior. --- .../reflex/xy_reflex_demo/xy_reflex_demo.py | 5 +- python/reflex-xy/README.md | 3 +- python/reflex-xy/reflex_xy/assets/XYChart.jsx | 52 ++++++++++++------- python/reflex-xy/reflex_xy/events.py | 11 ++-- spec/design/reflex-integration.md | 12 +++-- tests/reflex_adapter/test_component.py | 2 +- 6 files changed, 56 insertions(+), 29 deletions(-) diff --git a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py index b9c2825e..91124a26 100644 --- a/examples/reflex/xy_reflex_demo/xy_reflex_demo.py +++ b/examples/reflex/xy_reflex_demo/xy_reflex_demo.py @@ -262,8 +262,9 @@ def set_bins(self, value: list[int | float]): @rx.event def on_view(self, event: reflex_xy.ViewChangeEvent): # `event` is the v1 view-change envelope; `x_domain` is the reported - # [x0, x1] window (debounced by the wrapper). Store the window; the - # `detail` figure var depends on it and recomputes. + # [x0, x1] window (throttled by the wrapper, streaming during the + # gesture). Store the window; the `detail` figure var depends on it + # and recomputes. x_domain = event.get("x_domain") or [0.0, 0.0] self.view_x0 = float(x_domain[0]) self.view_x1 = float(x_domain[1]) diff --git a/python/reflex-xy/README.md b/python/reflex-xy/README.md index b4d66760..87106ece 100644 --- a/python/reflex-xy/README.md +++ b/python/reflex-xy/README.md @@ -113,7 +113,8 @@ def index(): Selection JSON is capped and reports `total_count` plus `truncated`; call `reflex_xy.resolve_selection(event)` in the handler when all canonical rows -are required. Hover is latest-wins throttled, view changes are debounced, and +are required. Hover and view changes are latest-wins throttled (view changes +stream live during a gesture and always end on a `final`-phase event), and view/selection state survives dependent republishes without feedback events. The complete envelopes, limits, clear/shared-handler/viewport examples, and static-versus-live availability are documented in diff --git a/python/reflex-xy/reflex_xy/assets/XYChart.jsx b/python/reflex-xy/reflex_xy/assets/XYChart.jsx index 39293cbd..34e00c0c 100644 --- a/python/reflex-xy/reflex_xy/assets/XYChart.jsx +++ b/python/reflex-xy/reflex_xy/assets/XYChart.jsx @@ -42,7 +42,7 @@ import { ChartView, decodeFrame, renderStandalone } from "./xy_client.js"; // Opt-in console tracing: localStorage.setItem("xy_debug", "1") const DEBUG = globalThis.localStorage?.getItem?.("xy_debug") === "1"; const HOVER_THROTTLE_MS = 120; -const VIEW_DEBOUNCE_MS = 200; +const VIEW_THROTTLE_MS = 120; const dbg = (...args) => DEBUG && console.log( @@ -311,26 +311,42 @@ export function XYChart(props) { }, HOVER_THROTTLE_MS); }; - const dispatchView = (m) => { - if (!cbRef.current.onViewChange || m.source === "linked" || m.source === "republish") return; - pendingView = m; - if (viewTimer !== null) clearTimeout(viewTimer); + const emitView = (m) => { + if (destroyed || !m || !cbRef.current.onViewChange) return; + cbRef.current.onViewChange({ + version: 1, + type: "view_change", + token, + x_domain: [m.x0, m.x1], + y_domain: [m.y0, m.y1], + source: m.source, + phase: m.phase === "update" ? "update" : "final", + }); + }; + + // Leading + trailing throttle, latest-wins: the first event of a gesture + // dispatches immediately so on_view_change-driven charts track pan/zoom + // live; the trailing flush guarantees the resting "final" view lands. + const openViewWindow = () => { viewTimer = setTimeout(() => { viewTimer = null; - const latest = pendingView; - pendingView = null; - if (!destroyed && latest && cbRef.current.onViewChange) { - cbRef.current.onViewChange({ - version: 1, - type: "view_change", - token, - x_domain: [latest.x0, latest.x1], - y_domain: [latest.y0, latest.y1], - source: latest.source, - phase: "final", - }); + if (pendingView !== null) { + const latest = pendingView; + pendingView = null; + emitView(latest); + openViewWindow(); } - }, VIEW_DEBOUNCE_MS); + }, VIEW_THROTTLE_MS); + }; + + const dispatchView = (m) => { + if (!cbRef.current.onViewChange || m.source === "linked" || m.source === "republish") return; + if (viewTimer === null) { + emitView(m); + openViewWindow(); + } else { + pendingView = m; + } }; const comm = { diff --git a/python/reflex-xy/reflex_xy/events.py b/python/reflex-xy/reflex_xy/events.py index 86e6b445..2a0e5c75 100644 --- a/python/reflex-xy/reflex_xy/events.py +++ b/python/reflex-xy/reflex_xy/events.py @@ -177,7 +177,12 @@ class SelectEndEvent(TypedDict): class ViewChangeEvent(TypedDict): - """``on_view_change`` — debounced final viewport after pan/zoom. + """``on_view_change`` — throttled viewport stream during and after pan/zoom. + + Dispatches are leading+trailing throttled (latest-wins): ``update``-phase + events stream while the gesture is in progress so dependent charts track + it live, and the resting viewport always arrives as a final ``final``-phase + event. Handlers that only care about settled views can filter on ``phase``. Shape:: @@ -188,7 +193,7 @@ class ViewChangeEvent(TypedDict): "x_domain": [x0, x1], # f64 data-space window "y_domain": [y0, y1], "source": str, # gesture: pan/zoom/keyboard/... - "phase": "final", + "phase": "update" | "final", } """ @@ -198,4 +203,4 @@ class ViewChangeEvent(TypedDict): x_domain: list[float] # [x0, x1] y_domain: list[float] # [y0, y1] source: str # gesture that produced the view (pan/zoom/keyboard/...) - phase: Literal["final"] + phase: Literal["update", "final"] diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index 95211424..aa091110 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -151,8 +151,8 @@ totality contract). This section records only what is specific to this host. **`view_change` does not reach the kernel here.** The wrapper intercepts the outgoing message and invokes the Reflex `on_view_change` prop directly -(`python/reflex-xy/reflex_xy/assets/XYChart.jsx:164-169`), because the -namespace registers no Python-side view callback (§5). Every other request +(`dispatchView` in `python/reflex-xy/reflex_xy/assets/XYChart.jsx`), because +the namespace registers no Python-side view callback (§5). Every other request type crosses the socket unchanged and is dispatched by the shared `handle_message`. @@ -496,8 +496,12 @@ def shared(self, event: dict): ``` View events are `{version, type: "view_change", token, x_domain, y_domain, -source, phase: "final"}`. User changes are trailing-edge debounced for 200 ms; -linked and republish sources are suppressed. Hover events are latest-wins and +source, phase: "update" | "final"}`. User changes are throttled to one +dispatch per 120 ms with a leading edge and a latest-wins trailing flush: +`update`-phase events stream while the gesture is in progress (this is what +lets an `on_view_change`-computed detail chart track a pan/zoom live), and the +resting viewport always lands as the last event with `phase: "final"`. Linked +and republish sources are suppressed. Hover events are latest-wins and throttled to one dispatch per 120 ms. For viewport synchronization: ```python diff --git a/tests/reflex_adapter/test_component.py b/tests/reflex_adapter/test_component.py index 02accf66..e04d8651 100644 --- a/tests/reflex_adapter/test_component.py +++ b/tests/reflex_adapter/test_component.py @@ -135,7 +135,7 @@ def test_semantic_event_wrapper_contracts_are_present(): assert "interaction.view_change = true" in source assert "include_rows: true" in source assert "HOVER_THROTTLE_MS = 120" in source - assert "VIEW_DEBOUNCE_MS = 200" in source + assert "VIEW_THROTTLE_MS = 120" in source assert "envelope.v = payloadVersion" in source assert "restoreSelectionSeqs.delete(message.seq)" in source assert "restoringSelection" not in source