From d7161b7c680a28b9e53546806081db3fee5260db Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 13 May 2026 17:39:49 -0400 Subject: [PATCH 1/4] batch/debounce websocket set_props --- .../src/MessageRouter.ts | 56 ++++++++++++++++++- @plotly/dash-websocket-worker/src/types.ts | 14 +++++ dash/backends/_fastapi.py | 4 +- dash/backends/_quart.py | 6 +- dash/backends/ws.py | 53 ++++++++++++++++-- .../src/observers/websocketObserver.ts | 19 ++++++- dash/dash-renderer/src/utils/workerClient.ts | 11 ++++ dash/dash.py | 2 + 8 files changed, 154 insertions(+), 11 deletions(-) diff --git a/@plotly/dash-websocket-worker/src/MessageRouter.ts b/@plotly/dash-websocket-worker/src/MessageRouter.ts index 68a9f4bfc2..b97effa08b 100644 --- a/@plotly/dash-websocket-worker/src/MessageRouter.ts +++ b/@plotly/dash-websocket-worker/src/MessageRouter.ts @@ -64,9 +64,16 @@ export class MessageRouter { /** * Handle a message from the WebSocket server. - * @param message The message from the server + * Messages may be batched as arrays for efficiency. + * @param message The message from the server (single message or array) */ public handleServerMessage(message: unknown): void { + // Handle batched messages (array of messages) + if (Array.isArray(message)) { + this.handleBatchedMessages(message); + return; + } + const msg = message as WorkerMessage; const rendererId = msg.rendererId; @@ -92,6 +99,53 @@ export class MessageRouter { } } + /** + * Handle a batch of messages from the server. + * Groups set_props by renderer and forwards as a single batch message. + * @param messages Array of messages + */ + private handleBatchedMessages(messages: unknown[]): void { + // Group set_props by renderer, keep others separate + const setPropsPayloadsByRenderer: Map = new Map(); + const otherMessages: WorkerMessage[] = []; + + for (const message of messages) { + const msg = message as WorkerMessage; + if (msg.type === WorkerMessageType.SET_PROPS) { + const setPropsMsg = msg as SetPropsMessage; + const rendererId = setPropsMsg.rendererId; + if (!setPropsPayloadsByRenderer.has(rendererId)) { + setPropsPayloadsByRenderer.set(rendererId, []); + } + setPropsPayloadsByRenderer.get(rendererId)!.push(setPropsMsg.payload); + } else { + otherMessages.push(msg); + } + } + + // Forward batched set_props to each renderer + for (const [rendererId, payloads] of setPropsPayloadsByRenderer) { + const port = this.renderers.get(rendererId); + if (port) { + try { + port.postMessage({ + type: WorkerMessageType.SET_PROPS_BATCH, + rendererId, + payload: payloads + }); + } catch (error) { + console.warn(`Failed to forward batch to renderer ${rendererId}, removing`); + this.renderers.delete(rendererId); + } + } + } + + // Forward other messages individually + for (const msg of otherMessages) { + this.handleServerMessage(msg); + } + } + /** * Send a message to all connected renderers. * @param message The message to broadcast diff --git a/@plotly/dash-websocket-worker/src/types.ts b/@plotly/dash-websocket-worker/src/types.ts index fac282b5e1..0bf06d68b1 100644 --- a/@plotly/dash-websocket-worker/src/types.ts +++ b/@plotly/dash-websocket-worker/src/types.ts @@ -13,6 +13,7 @@ export enum WorkerMessageType { DISCONNECTED = 'disconnected', CALLBACK_RESPONSE = 'callback_response', SET_PROPS = 'set_props', + SET_PROPS_BATCH = 'set_props_batch', GET_PROPS_REQUEST = 'get_props_request', ERROR = 'error' } @@ -88,6 +89,18 @@ export interface SetPropsMessage extends WorkerMessage { }; } +/** + * Message from worker to renderer to set props for multiple components at once. + * Used for batching multiple set_props calls for efficiency. + */ +export interface SetPropsBatchMessage extends WorkerMessage { + type: WorkerMessageType.SET_PROPS_BATCH; + payload: Array<{ + componentId: string; + props: Record; + }>; +} + /** * Message from worker to renderer requesting prop values. */ @@ -144,6 +157,7 @@ export type AnyWorkerMessage = | CallbackRequestMessage | CallbackResponseMessage | SetPropsMessage + | SetPropsBatchMessage | GetPropsRequestMessage | GetPropsResponseMessage | ErrorMessage diff --git a/dash/backends/_fastapi.py b/dash/backends/_fastapi.py index 9e06bd418c..c46fb4ffc5 100644 --- a/dash/backends/_fastapi.py +++ b/dash/backends/_fastapi.py @@ -725,8 +725,10 @@ async def websocket_handler(websocket: WebSocket): pending_callbacks: Dict[str, concurrent.futures.Future] = {} # Start sender task to drain outbound queue (sends pre-serialized text) + # pylint: disable=protected-access + batch_delay = getattr(dash_app, "_websocket_batch_delay", 0.005) sender_task = asyncio.create_task( - run_ws_sender(websocket.send_text, outbound_queue) + run_ws_sender(websocket.send_text, outbound_queue, batch_delay) ) try: diff --git a/dash/backends/_quart.py b/dash/backends/_quart.py index 0916f206fb..881fd6466f 100644 --- a/dash/backends/_quart.py +++ b/dash/backends/_quart.py @@ -566,7 +566,11 @@ async def websocket_handler(): # pylint: disable=too-many-branches pending_callbacks: Dict[str, concurrent.futures.Future] = {} # Start sender task to drain outbound queue (sends pre-serialized text) - sender_task = asyncio.create_task(run_ws_sender(ws.send, outbound_queue)) + # pylint: disable=protected-access + batch_delay = getattr(dash_app, "_websocket_batch_delay", 0.005) + sender_task = asyncio.create_task( + run_ws_sender(ws.send, outbound_queue, batch_delay) + ) try: shutdown_event = self._ws_shutdown_event diff --git a/dash/backends/ws.py b/dash/backends/ws.py index 041241823e..8730ee009a 100644 --- a/dash/backends/ws.py +++ b/dash/backends/ws.py @@ -29,6 +29,7 @@ SHUTDOWN_SIGNAL = "__shutdown__" DISCONNECTED = "__disconnected__" +FLUSH_SIGNAL = "__flush__" class DashWebsocketCallback: @@ -184,29 +185,67 @@ def create_ws_context( async def run_ws_sender( - send_text: Callable[[str], Any], outbound_queue: janus.Queue[str] + send_text: Callable[[str], Any], + outbound_queue: janus.Queue[str], + batch_delay: float = 0.005, ) -> None: """Sender coroutine - drains queue and sends to WebSocket. This coroutine runs in the main event loop and handles sending messages that are queued by worker threads via janus.Queue. - Messages are pre-serialized strings (using to_json). + Messages are pre-serialized strings (using to_json). For efficiency, + this function batches messages that arrive within batch_delay of each + other, sending them as a JSON array. When no message arrives within + the window, all collected messages are sent immediately. Args: send_text: Async function to send text data over WebSocket outbound_queue: janus.Queue instance for receiving messages (strings) + batch_delay: Time in seconds to wait for additional messages (default: 5ms) """ + q = outbound_queue.async_q + messages: list = [] try: while True: - msg = await outbound_queue.async_q.get() - if msg == SHUTDOWN_SIGNAL: - break - await send_text(msg) + # Wait indefinitely for first message, then use timeout for batching + timeout = batch_delay if messages else None + try: + msg = await asyncio.wait_for(q.get(), timeout=timeout) + if msg == SHUTDOWN_SIGNAL: + if messages: + await _send_batched(send_text, messages) + return + if msg == FLUSH_SIGNAL: + if messages: + await _send_batched(send_text, messages) + messages = [] + continue + messages.append(msg) + except asyncio.TimeoutError: + await _send_batched(send_text, messages) + messages = [] except asyncio.CancelledError: pass +async def _send_batched(send_text: Callable[[str], Any], messages: list) -> None: + """Send messages as a batch. + + Single messages are sent as-is. Multiple messages are wrapped + in a JSON array without re-parsing - just string concatenation. + + Args: + send_text: Async function to send text data over WebSocket + messages: List of pre-serialized JSON message strings + """ + if len(messages) == 1: + await send_text(messages[0]) + else: + # Wrap in array: "[msg1,msg2,msg3]" + await send_text("[" + ",".join(messages) + "]") + + def make_callback_done_handler( outbound_queue: janus.Queue[str], pending_callbacks: Dict[str, concurrent.futures.Future], @@ -269,6 +308,8 @@ def on_done(f: concurrent.futures.Future) -> None: ) finally: pending_callbacks.pop(request_id, None) + if not shutdown_event.is_set(): + outbound_queue.sync_q.put_nowait(FLUSH_SIGNAL) return on_done diff --git a/dash/dash-renderer/src/observers/websocketObserver.ts b/dash/dash-renderer/src/observers/websocketObserver.ts index 7b75fada38..f01cb2928c 100644 --- a/dash/dash-renderer/src/observers/websocketObserver.ts +++ b/dash/dash-renderer/src/observers/websocketObserver.ts @@ -11,6 +11,7 @@ import {IStoreState} from '../store'; import {updateProps, notifyObservers, setPaths} from '../actions'; import {parsePatchProps} from '../actions/patch'; import {computePaths, getPath} from '../actions/paths'; +import {batch} from 'react-redux'; import { getWorkerClient, SetPropsPayload, @@ -72,8 +73,8 @@ export async function initializeWebSocket( const workerClient = getWorkerClient(); - // Handle SET_PROPS messages - workerClient.onSetProps = (payload: SetPropsPayload) => { + // Helper to process a single set_props payload + const processSetProps = (payload: SetPropsPayload) => { const {componentId, props: rawProps} = payload; const parsedId = parseComponentId(componentId); const state = store.getState(); @@ -129,6 +130,20 @@ export async function initializeWebSocket( } }; + // Handle single SET_PROPS message + workerClient.onSetProps = (payload: SetPropsPayload) => { + processSetProps(payload); + }; + + // Handle batched SET_PROPS_BATCH message + workerClient.onSetPropsBatch = (payloads: SetPropsPayload[]) => { + batch(() => { + for (const payload of payloads) { + processSetProps(payload); + } + }); + }; + // Handle GET_PROPS_REQUEST messages workerClient.onGetPropsRequest = ( requestId: string, diff --git a/dash/dash-renderer/src/utils/workerClient.ts b/dash/dash-renderer/src/utils/workerClient.ts index c16594f8ef..01584bf20c 100644 --- a/dash/dash-renderer/src/utils/workerClient.ts +++ b/dash/dash-renderer/src/utils/workerClient.ts @@ -14,6 +14,7 @@ export enum WorkerMessageType { DISCONNECTED = 'disconnected', CALLBACK_RESPONSE = 'callback_response', SET_PROPS = 'set_props', + SET_PROPS_BATCH = 'set_props_batch', GET_PROPS_REQUEST = 'get_props_request', ERROR = 'error' } @@ -58,6 +59,10 @@ class WorkerClient { /** Callback when SET_PROPS message is received */ public onSetProps: ((payload: SetPropsPayload) => void) | null = null; + /** Callback when SET_PROPS_BATCH message is received */ + public onSetPropsBatch: ((payloads: SetPropsPayload[]) => void) | null = + null; + /** Callback when GET_PROPS_REQUEST message is received */ public onGetPropsRequest: | ((requestId: string, payload: GetPropsRequestPayload) => void) @@ -289,6 +294,12 @@ class WorkerClient { } break; + case WorkerMessageType.SET_PROPS_BATCH: + if (this.onSetPropsBatch) { + this.onSetPropsBatch(message.payload); + } + break; + case WorkerMessageType.GET_PROPS_REQUEST: if (this.onGetPropsRequest) { this.onGetPropsRequest(message.requestId, message.payload); diff --git a/dash/dash.py b/dash/dash.py index f0821abef2..e5316f3be9 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -486,6 +486,7 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches websocket_callbacks: Optional[bool] = False, websocket_allowed_origins: Optional[List[str]] = None, websocket_inactivity_timeout: Optional[int] = 300000, + websocket_batch_delay: Optional[float] = 0.005, **obsolete, ): @@ -645,6 +646,7 @@ def __init__( # pylint: disable=too-many-statements, too-many-branches self._websocket_callbacks = websocket_callbacks self._websocket_allowed_origins = websocket_allowed_origins or [] self._websocket_inactivity_timeout = websocket_inactivity_timeout + self._websocket_batch_delay = websocket_batch_delay self.logger = logging.getLogger(__name__) From 3ecc049536bb8c2141bd809c3c62c03e8f119554 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 13 May 2026 18:28:06 -0400 Subject: [PATCH 2/4] fix batched heartbeat ack disconnecting --- .../dash-websocket-worker/src/MessageRouter.ts | 4 ++++ .../src/WebSocketManager.ts | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/@plotly/dash-websocket-worker/src/MessageRouter.ts b/@plotly/dash-websocket-worker/src/MessageRouter.ts index b97effa08b..09cc9fd4fa 100644 --- a/@plotly/dash-websocket-worker/src/MessageRouter.ts +++ b/@plotly/dash-websocket-worker/src/MessageRouter.ts @@ -111,6 +111,10 @@ export class MessageRouter { for (const message of messages) { const msg = message as WorkerMessage; + // Skip heartbeat_ack - already handled by WebSocketManager + if ((msg as any).type === 'heartbeat_ack') { + continue; + } if (msg.type === WorkerMessageType.SET_PROPS) { const setPropsMsg = msg as SetPropsMessage; const rendererId = setPropsMsg.rendererId; diff --git a/@plotly/dash-websocket-worker/src/WebSocketManager.ts b/@plotly/dash-websocket-worker/src/WebSocketManager.ts index 2d32c7e8ca..6deae1d6ca 100644 --- a/@plotly/dash-websocket-worker/src/WebSocketManager.ts +++ b/@plotly/dash-websocket-worker/src/WebSocketManager.ts @@ -195,7 +195,23 @@ export class WebSocketManager { try { const data = JSON.parse(event.data); - // Handle heartbeat acknowledgment - does NOT count as activity + // Handle batched messages - check for heartbeat_ack in the batch + if (Array.isArray(data)) { + for (const msg of data) { + if (msg && msg.type === 'heartbeat_ack') { + this.clearHeartbeatTimeout(); + break; + } + } + // Track activity and forward batch for processing + this.lastActivityTime = Date.now(); + if (this.onMessage) { + this.onMessage(data); + } + return; + } + + // Handle single heartbeat acknowledgment - does NOT count as activity if (data.type === 'heartbeat_ack') { this.clearHeartbeatTimeout(); return; From 4538d66066985cfb50afcaeaef2cf403fd96469c Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 19 May 2026 13:40:14 -0400 Subject: [PATCH 3/4] address review comments --- .../src/MessageRouter.ts | 50 +++++++++++-------- .../src/WebSocketManager.ts | 34 ++++++++++--- @plotly/dash-websocket-worker/src/types.ts | 5 +- dash/backends/ws.py | 10 ++-- .../src/observers/websocketObserver.ts | 4 +- tests/websocket/test_ws_props.py | 42 ++++++++++++++++ 6 files changed, 109 insertions(+), 36 deletions(-) diff --git a/@plotly/dash-websocket-worker/src/MessageRouter.ts b/@plotly/dash-websocket-worker/src/MessageRouter.ts index 09cc9fd4fa..157da4fcfc 100644 --- a/@plotly/dash-websocket-worker/src/MessageRouter.ts +++ b/@plotly/dash-websocket-worker/src/MessageRouter.ts @@ -70,7 +70,7 @@ export class MessageRouter { public handleServerMessage(message: unknown): void { // Handle batched messages (array of messages) if (Array.isArray(message)) { - this.handleBatchedMessages(message); + this.handleBatchedMessages(message as WorkerMessage[]); return; } @@ -102,26 +102,32 @@ export class MessageRouter { /** * Handle a batch of messages from the server. * Groups set_props by renderer and forwards as a single batch message. - * @param messages Array of messages + * + * Note: set_props messages are processed before other message types. + * This is intentional - batching groups all set_props for efficiency, + * and the relative order within set_props is preserved. + * + * @param messages Array of messages (may include server-only types like heartbeat_ack) */ - private handleBatchedMessages(messages: unknown[]): void { + private handleBatchedMessages(messages: WorkerMessage[]): void { // Group set_props by renderer, keep others separate const setPropsPayloadsByRenderer: Map = new Map(); const otherMessages: WorkerMessage[] = []; - for (const message of messages) { - const msg = message as WorkerMessage; - // Skip heartbeat_ack - already handled by WebSocketManager - if ((msg as any).type === 'heartbeat_ack') { + for (const msg of messages) { + // Skip heartbeat_ack - server-only type, already handled by WebSocketManager + if (msg.type === WorkerMessageType.HEARTBEAT_ACK) { continue; } if (msg.type === WorkerMessageType.SET_PROPS) { const setPropsMsg = msg as SetPropsMessage; const rendererId = setPropsMsg.rendererId; - if (!setPropsPayloadsByRenderer.has(rendererId)) { - setPropsPayloadsByRenderer.set(rendererId, []); + const existing = setPropsPayloadsByRenderer.get(rendererId); + if (existing) { + existing.push(setPropsMsg.payload); + } else { + setPropsPayloadsByRenderer.set(rendererId, [setPropsMsg.payload]); } - setPropsPayloadsByRenderer.get(rendererId)!.push(setPropsMsg.payload); } else { otherMessages.push(msg); } @@ -130,17 +136,19 @@ export class MessageRouter { // Forward batched set_props to each renderer for (const [rendererId, payloads] of setPropsPayloadsByRenderer) { const port = this.renderers.get(rendererId); - if (port) { - try { - port.postMessage({ - type: WorkerMessageType.SET_PROPS_BATCH, - rendererId, - payload: payloads - }); - } catch (error) { - console.warn(`Failed to forward batch to renderer ${rendererId}, removing`); - this.renderers.delete(rendererId); - } + if (!port) { + console.warn(`Renderer ${rendererId} not found for batch, skipping`); + continue; + } + try { + port.postMessage({ + type: WorkerMessageType.SET_PROPS_BATCH, + rendererId, + payload: payloads + }); + } catch (error) { + console.warn(`Failed to forward batch to renderer ${rendererId}, removing`); + this.renderers.delete(rendererId); } } diff --git a/@plotly/dash-websocket-worker/src/WebSocketManager.ts b/@plotly/dash-websocket-worker/src/WebSocketManager.ts index 6deae1d6ca..d96a0d8e68 100644 --- a/@plotly/dash-websocket-worker/src/WebSocketManager.ts +++ b/@plotly/dash-websocket-worker/src/WebSocketManager.ts @@ -1,3 +1,12 @@ +/** + * Message received from the server. + * Can be a WorkerMessage or a heartbeat_ack. + */ +interface ServerMessage { + type: string; + [key: string]: unknown; +} + /** * Configuration options for WebSocket connection. */ @@ -193,20 +202,29 @@ export class WebSocketManager { private handleMessage(event: MessageEvent): void { try { - const data = JSON.parse(event.data); + const data: ServerMessage | ServerMessage[] = JSON.parse(event.data); // Handle batched messages - check for heartbeat_ack in the batch if (Array.isArray(data)) { + let hasHeartbeatAck = false; + let hasOtherMessages = false; for (const msg of data) { - if (msg && msg.type === 'heartbeat_ack') { - this.clearHeartbeatTimeout(); - break; + if (msg.type === 'heartbeat_ack') { + hasHeartbeatAck = true; + } else { + hasOtherMessages = true; } } - // Track activity and forward batch for processing - this.lastActivityTime = Date.now(); - if (this.onMessage) { - this.onMessage(data); + if (hasHeartbeatAck) { + this.clearHeartbeatTimeout(); + } + // Only track activity if there are non-heartbeat messages + // This matches the single-message behavior + if (hasOtherMessages) { + this.lastActivityTime = Date.now(); + if (this.onMessage) { + this.onMessage(data); + } } return; } diff --git a/@plotly/dash-websocket-worker/src/types.ts b/@plotly/dash-websocket-worker/src/types.ts index 0bf06d68b1..5d1ff80bf0 100644 --- a/@plotly/dash-websocket-worker/src/types.ts +++ b/@plotly/dash-websocket-worker/src/types.ts @@ -15,7 +15,10 @@ export enum WorkerMessageType { SET_PROPS = 'set_props', SET_PROPS_BATCH = 'set_props_batch', GET_PROPS_REQUEST = 'get_props_request', - ERROR = 'error' + ERROR = 'error', + + // Server -> Worker (not forwarded to renderer) + HEARTBEAT_ACK = 'heartbeat_ack' } /** diff --git a/dash/backends/ws.py b/dash/backends/ws.py index 8730ee009a..f44913d873 100644 --- a/dash/backends/ws.py +++ b/dash/backends/ws.py @@ -202,10 +202,11 @@ async def run_ws_sender( Args: send_text: Async function to send text data over WebSocket outbound_queue: janus.Queue instance for receiving messages (strings) - batch_delay: Time in seconds to wait for additional messages (default: 5ms) + batch_delay: Time in seconds to wait for additional messages (default: 5ms). + Set to 0 to disable batching and send messages immediately. """ q = outbound_queue.async_q - messages: list = [] + messages: list[str] = [] try: while True: # Wait indefinitely for first message, then use timeout for batching @@ -221,7 +222,10 @@ async def run_ws_sender( await _send_batched(send_text, messages) messages = [] continue - messages.append(msg) + if not batch_delay: + await send_text(msg) + else: + messages.append(msg) except asyncio.TimeoutError: await _send_batched(send_text, messages) messages = [] diff --git a/dash/dash-renderer/src/observers/websocketObserver.ts b/dash/dash-renderer/src/observers/websocketObserver.ts index f01cb2928c..daa3238773 100644 --- a/dash/dash-renderer/src/observers/websocketObserver.ts +++ b/dash/dash-renderer/src/observers/websocketObserver.ts @@ -131,9 +131,7 @@ export async function initializeWebSocket( }; // Handle single SET_PROPS message - workerClient.onSetProps = (payload: SetPropsPayload) => { - processSetProps(payload); - }; + workerClient.onSetProps = processSetProps; // Handle batched SET_PROPS_BATCH message workerClient.onSetPropsBatch = (payloads: SetPropsPayload[]) => { diff --git a/tests/websocket/test_ws_props.py b/tests/websocket/test_ws_props.py index a86402954d..88af40f99f 100644 --- a/tests/websocket/test_ws_props.py +++ b/tests/websocket/test_ws_props.py @@ -399,6 +399,48 @@ async def update_list(n): assert dash_duo.get_logs() == [] +def test_ws047b_set_props_batch_many_updates(dash_duo): + """Test that many rapid set_props calls are batched and all arrive correctly.""" + app = Dash(__name__, backend="fastapi", websocket_callbacks=True) + + app.layout = html.Div( + [ + html.Button("Update Many", id="btn"), + *[html.Div("0", id=f"output-{i}") for i in range(10)], + html.Div(id="result"), + ] + ) + + @app.callback(Output("result", "children"), Input("btn", "n_clicks")) + async def update_many(n): + if not n: + raise PreventUpdate + + # Rapid-fire set_props calls without any await between them + # These should be batched together by the server + for i in range(10): + set_props(f"output-{i}", {"children": f"{n}"}) + + return f"Done {n}" + + dash_duo.start_server(app) + + dash_duo.find_element("#btn").click() + + # All outputs should be updated + dash_duo.wait_for_text_to_equal("#result", "Done 1", timeout=10) + for i in range(10): + dash_duo.wait_for_text_to_equal(f"#output-{i}", "1") + + # Click again to verify batching works consistently + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#result", "Done 2", timeout=10) + for i in range(10): + dash_duo.wait_for_text_to_equal(f"#output-{i}", "2") + + assert dash_duo.get_logs() == [] + + def test_ws048_set_props_dynamic_match_callback(dash_duo): """Test set_props injecting components with pattern-matching IDs that trigger MATCH callbacks.""" app = Dash(__name__, backend="fastapi", websocket_callbacks=True) From c35799043fdc550a505fc31b25558216fa2d406a Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 19 May 2026 14:38:15 -0400 Subject: [PATCH 4/4] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c0fc585b..3f16cf6e80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [UNRELEASED] ## Added +- [#3783](https://github.com/plotly/dash/pull/3783) Add batching/debouncing for websocket `set_props` messages to reduce lag when updating multiple components in a loop. Configurable via `websocket_batch_delay` (default 5ms, set to 0 to disable). - [#3669](https://github.com/plotly/dash/pull/3669) Selection for DataTable cleared with custom action settings - [#3680](https://github.com/plotly/dash/pull/3680) Added `search_order` prop to `Dropdown` to allow users to preserve original option order during search - Added `csrf_token_name` and `csrf_header_name` config options to allow configuring the CSRF cookie and header names. Fixes [#729](https://github.com/plotly/dash/issues/729)