diff --git a/.ai/ARCHITECTURE.md b/.ai/ARCHITECTURE.md index d90f670f9b..92c56a8b00 100644 --- a/.ai/ARCHITECTURE.md +++ b/.ai/ARCHITECTURE.md @@ -1014,10 +1014,29 @@ Only "persistent callbacks" (callbacks with no Output and no Input that use only - `set_props(component_id, props_dict)` - Stream prop updates immediately to client - `ctx.websocket` - Get WebSocket interface (returns `None` if not in WS context) - `ws.is_shutdown` - Check if the WebSocket connection has been closed -- `await ws.get_prop(component_id, prop_name)` - Read current prop value from client +- `await ws.get_prop(component_id, prop_name, timeout=30.0, *, path=None)` - Read a full or nested prop value - `await ws.set_prop(component_id, prop_name, value)` - Set single prop (async version) - `await ws.close(code, reason)` - Close the WebSocket connection +### Partial Reads with get_prop + +Use the keyword-only `path` argument with the same string keys and integer list +indices supported by `Patch`: + +```python +event = await ws.get_prop( + 'store', 'data', path=['event', 'target', 'value'] +) +last = await ws.get_prop('store', 'data', path=['records', -1, 'value']) +``` + +- Omit `path`, or use `None` or `[]`, to read the complete property. +- Negative indices count from the end of a list. +- Missing locations return `None`; invalid paths raise before sending a request. +- Falsy values and empty containers are preserved. +- The renderer resolves the path before WebSocket serialization, so only the + selected value is returned and component props are never modified. + ### Connection Hooks Use hooks to validate connections and messages: diff --git a/@plotly/dash-websocket-worker/src/types.ts b/@plotly/dash-websocket-worker/src/types.ts index fdc8112ada..b73203f5be 100644 --- a/@plotly/dash-websocket-worker/src/types.ts +++ b/@plotly/dash-websocket-worker/src/types.ts @@ -114,6 +114,8 @@ export interface GetPropsRequestMessage extends WorkerMessage { payload: { componentId: string; properties: string[]; + /** Optional location within each requested property. */ + path?: (string | number)[]; }; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6823eab6cf..51e3212852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- [#3977](https://github.com/plotly/dash/pull/3977) Add partial WebSocket prop reads with `get_prop(..., path=...)`. Closes [#3975](https://github.com/plotly/dash/issues/3975). - [#3765](https://github.com/plotly/dash/pull/3765) Add opt-in partial pattern matching for callback `Input`, `Output`, and `State` dependencies via `partial_pattern=True`. Dictionary ID patterns can now match component IDs containing additional keys, and partial patterns can be combined with `ALL` and `MATCH` wildcards. Fixes [#3764](https://github.com/plotly/dash/issues/3764). - [#3646](https://github.com/plotly/dash/pull/3646) Experimental support for React 19. The default is still React 18.3.1; to use React 19 set the environment variable `REACT_VERSION=19.2.4` before running your app, or call `dash._dash_renderer._set_react_version("19.2.4")` inside the app. React 19 has no official UMD builds, so Dash serves the [`umd-react`](https://www.npmjs.com/package/umd-react) package, together with a compatibility shim loaded after react-dom and before any component package. The shim keeps component libraries built against React <=18 (e.g. dash-bootstrap-components, dash-mantine-components) working under React 19: it stubs the removed `ReactCurrentOwner` internals, redirects the legacy element `$$typeof` symbol so pre-bundled React 18 jsx-runtimes produce elements React 19 accepts (error #525), and exposes a global `react/jsx-runtime` (`window.ReactJSXRuntime`) that Dash's own component bundles externalize to. Component library authors adopting this convention should copy the defensive `jsxRuntimeExternal` webpack external from `components/dash-core-components/webpack.config.js` rather than a bare `'ReactJSXRuntime'` string: it falls back to a `React.createElement`-based runtime when the global is missing, so the same build also works on Dash versions older than this release. - [#3925](https://github.com/plotly/dash/pull/3925) Add optional callback request payload compression for server-side callbacks via `compress_payload` and `compress_threshold` callback parameters (default threshold: 5,000 bytes). When enabled and the request body exceeds the threshold, the renderer sends gzip-compressed binary payloads with `Content-Encoding: gzip`, and Dash transparently decompresses on the server (Flask, FastAPI, and Quart). This can significantly reduce callback roundtrip times for large client-to-server payloads. Fixes [#3924](https://github.com/plotly/dash/issues/3924). diff --git a/dash/backends/ws.py b/dash/backends/ws.py index 6dc2c0e926..d83bde9ed2 100644 --- a/dash/backends/ws.py +++ b/dash/backends/ws.py @@ -31,6 +31,23 @@ SHUTDOWN_SIGNAL = "__shutdown__" DISCONNECTED = "__disconnected__" FLUSH_SIGNAL = "__flush__" +_JS_MAX_SAFE_INTEGER = 2**53 - 1 + + +def _validate_prop_path(path: Any) -> None: + """Validate a client-side partial-read path before sending a request.""" + if path is None: + return + if not isinstance(path, list): + raise TypeError("path must be a list of string keys and integer indices") + for index, key in enumerate(path): + if isinstance(key, bool) or not isinstance(key, (str, int)): + raise TypeError(f"path[{index}] must be a string or an integer") + if ( + isinstance(key, int) + and not -_JS_MAX_SAFE_INTEGER <= key <= _JS_MAX_SAFE_INTEGER + ): + raise ValueError(f"path[{index}] must be a JavaScript safe integer") class DashWebsocketCallback: @@ -147,9 +164,14 @@ async def set_prop(self, component_id: str, prop_name: str, value: Any) -> None: self.set_prop_sync(component_id, prop_name, value) async def get_prop( - self, component_id: str, prop_name: str, timeout: float = 30.0 + self, + component_id: str, + prop_name: str, + timeout: float = 30.0, + *, + path: list[str | int] | None = None, ) -> Any: - """Request current prop value from the client. + """Request a current prop value or a nested part of it from the client. On the event-loop path (``self._loop`` set, async callbacks) the wait uses an awaitable ``asyncio.Future`` so the connection loop is never blocked. On the @@ -160,11 +182,16 @@ async def get_prop( component_id: The component ID (string or stringified dict) prop_name: The property name to retrieve timeout: Timeout in seconds for waiting for response + path: Optional Patch-style list of string keys and integer list indices. + Negative indices count from the end of a list. Omit, pass None, or + use [] to read the complete property. Returns: - The current value of the property from the client's state + The complete property or selected value. Missing locations return None. Raises: + TypeError: If path is not a list of string keys and integer indices. + ValueError: If an index is outside the JavaScript safe integer range. WebsocketDisconnected: If the websocket connection has been closed. TimeoutError: If the response doesn't arrive within the timeout. """ @@ -175,12 +202,21 @@ async def get_prop( if pending_get_props is None: raise WebsocketDisconnected() + _validate_prop_path(path) + request_id = str(uuid.uuid4()) + payload: dict[str, Any] = { + "componentId": component_id, + "properties": [prop_name], + } + if path is not None: + payload["path"] = path + msg = { "type": "get_props_request", "rendererId": self._renderer_id, "requestId": request_id, - "payload": {"componentId": component_id, "properties": [prop_name]}, + "payload": payload, } if self._loop is not None: diff --git a/dash/dash-renderer/src/observers/websocketObserver.ts b/dash/dash-renderer/src/observers/websocketObserver.ts index 5384de32d4..310903f077 100644 --- a/dash/dash-renderer/src/observers/websocketObserver.ts +++ b/dash/dash-renderer/src/observers/websocketObserver.ts @@ -5,11 +5,12 @@ /* eslint-disable no-console */ import {Store} from 'redux'; -import {path} from 'ramda'; +import {has, path} from 'ramda'; import {IStoreState} from '../store'; import {updateProps, notifyObservers, setPaths} from '../actions'; import {parsePatchProps} from '../actions/patch'; +import {resolvePropPath} from '../utils/propPath'; import {computePaths, getPath} from '../actions/paths'; import {batch} from 'react-redux'; import { @@ -40,6 +41,33 @@ function parseComponentId( return componentId; } +function readRequestedProps( + componentProps: Record | undefined, + properties: string[], + propPath: (string | number)[] | undefined +): Record { + const result: Record = + propPath === undefined ? {} : Object.create(null); + + if (!componentProps) { + return result; + } + + for (const propName of properties) { + if (propPath === undefined) { + result[propName] = componentProps[propName]; + continue; + } + + const value = has(propName, componentProps) + ? componentProps[propName] + : undefined; + result[propName] = resolvePropPath(value, propPath) ?? null; + } + + return result; +} + /** * Initialize the WebSocket observer. * @@ -152,24 +180,17 @@ export async function initializeWebSocket( requestId: string, payload: GetPropsRequestPayload ) => { - const {componentId, properties} = payload; + const {componentId, properties, path: propPath} = payload; const parsedId = parseComponentId(componentId); const state = store.getState(); const componentPath = getPath(state.paths, parsedId); - const result: Record = {}; + let componentProps: Record | undefined; if (componentPath) { - const componentProps = path( - [...componentPath, 'props'], - state.layout - ) as Record | undefined; - - if (componentProps) { - for (const propName of properties) { - result[propName] = componentProps[propName]; - } - } + componentProps = path([...componentPath, 'props'], state.layout) as + | Record + | undefined; } else { console.warn( `GET_PROPS_REQUEST: Component ${componentId} not found in layout` @@ -177,6 +198,7 @@ export async function initializeWebSocket( } // Send the response + const result = readRequestedProps(componentProps, properties, propPath); workerClient.sendGetPropsResponse(requestId, result); }; diff --git a/dash/dash-renderer/src/utils/propPath.ts b/dash/dash-renderer/src/utils/propPath.ts new file mode 100644 index 0000000000..a335c30a92 --- /dev/null +++ b/dash/dash-renderer/src/utils/propPath.ts @@ -0,0 +1,51 @@ +import {has} from 'ramda'; + +type PropPathKey = string | number; + +function resolveArrayKey(value: unknown[], key: PropPathKey): unknown { + if (typeof key !== 'number' || !Number.isSafeInteger(key)) { + return undefined; + } + + const index = key < 0 ? value.length + key : key; + if (index < 0 || index >= value.length || !has(String(index), value)) { + return undefined; + } + + return value[index]; +} + +function resolveObjectKey(value: object, key: PropPathKey): unknown { + if (typeof key !== 'string' || !has(key, value)) { + return undefined; + } + + return (value as Record)[key]; +} + +/** + * Read a dictionary/list location without copying or changing the source value. + * Like Patch locations, negative indices are relative to the current list. + * String keys select own dictionary properties; integers select list entries. + * Missing or incompatible locations return undefined. Empty paths select the + * entire value. Iteration takes O(path.length) time and O(1) extra space; it + * never traverses siblings or clones the selected subtree. + */ +export function resolvePropPath(value: unknown, path: PropPathKey[]): unknown { + if (!Array.isArray(path)) { + return undefined; + } + + let current = value; + for (const key of path) { + if (Array.isArray(current)) { + current = resolveArrayKey(current, key); + continue; + } + if (current === null || typeof current !== 'object') { + return undefined; + } + current = resolveObjectKey(current, key); + } + return current; +} diff --git a/dash/dash-renderer/src/utils/workerClient.ts b/dash/dash-renderer/src/utils/workerClient.ts index 1c07a4c1c3..6ed212f041 100644 --- a/dash/dash-renderer/src/utils/workerClient.ts +++ b/dash/dash-renderer/src/utils/workerClient.ts @@ -37,6 +37,8 @@ export interface SetPropsPayload { export interface GetPropsRequestPayload { componentId: string; properties: string[]; + /** Optional location within each requested property. */ + path?: (string | number)[]; } /** Pending callback request */ diff --git a/dash/dash-renderer/tests/propPath.test.js b/dash/dash-renderer/tests/propPath.test.js new file mode 100644 index 0000000000..26cb309305 --- /dev/null +++ b/dash/dash-renderer/tests/propPath.test.js @@ -0,0 +1,114 @@ +import {expect} from 'chai'; +import {describe, it} from 'mocha'; +import {handlePatch} from '../src/actions/patch'; +import {resolvePropPath} from '../src/utils/propPath'; + +describe('partial prop reads', () => { + const data = Object.freeze({ + records: Object.freeze([ + Object.freeze({values: Object.freeze([0, false, '', null])}), + Object.freeze({values: Object.freeze([42, 'last'])}) + ]), + empty: Object.freeze([]), + object: Object.freeze({}), + '': 'empty key', + 'a.b[0]/c': 'literal', + 中文: 'unicode', + 0: 'string key' + }); + + [ + ['empty path', data, [], data], + ['object subtree', data, ['records', 0], data.records[0]], + ['mixed nesting', data, ['records', 1, 'values', 0], 42], + ['negative indices', data, ['records', -1, 'values', -1], 'last'], + ['zero', data, ['records', 0, 'values', 0], 0], + ['false', data, ['records', 0, 'values', 1], false], + ['empty string', data, ['records', 0, 'values', 2], ''], + ['null', data, ['records', 0, 'values', 3], null], + ['empty array', data, ['empty'], data.empty], + ['empty object', data, ['object'], data.object], + ['literal key', data, ['a.b[0]/c'], 'literal'], + ['empty key', data, [''], 'empty key'], + ['unicode key', data, ['中文'], 'unicode'], + ['numeric string key', data, ['0'], 'string key'], + ['missing key', data, ['missing'], undefined], + ['out-of-range index', data, ['records', 2], undefined], + ['negative overflow', data, ['records', -3], undefined], + ['string array index', data, ['records', '0'], undefined], + ['integer object key', data, [0], undefined], + [ + 'incompatible ancestor', + data, + ['records', 0, 'values', 0, 'x'], + undefined + ], + ['unsafe index', data.records, [2 ** 53], undefined] + ].forEach(([name, value, path, expected]) => { + it(name, () => { + expect(resolvePropPath(value, path)).to.equal(expected); + }); + }); + + it('reads own dictionary keys only', () => { + const value = JSON.parse( + '{"__proto__":{"value":1},"constructor":2,"hasOwnProperty":3}' + ); + expect(resolvePropPath(value, ['__proto__', 'value'])).to.equal(1); + expect(resolvePropPath(value, ['constructor'])).to.equal(2); + expect(resolvePropPath(value, ['hasOwnProperty'])).to.equal(3); + expect(resolvePropPath({}, ['toString'])).to.equal(undefined); + expect( + resolvePropPath(Object.create({inherited: 1}), ['inherited']) + ).to.equal(undefined); + }); + + it('reads objects without a prototype', () => { + const value = Object.assign(Object.create(null), { + selected: {value: 42} + }); + expect(resolvePropPath(value, ['selected', 'value'])).to.equal(42); + expect(resolvePropPath(value, ['missing'])).to.equal(undefined); + }); + + it('does not read inherited sparse array entries', () => { + const value = new Array(1); + const prototype = Object.create(Array.prototype, { + 0: { + get() { + throw new Error('Inherited array entry was accessed'); + } + } + }); + Object.setPrototypeOf(value, prototype); + + expect(resolvePropPath(value, [0])).to.equal(undefined); + }); + + it('matches Patch for an existing negative-index location', () => { + const location = ['records', -1, 'values', -1]; + const updated = handlePatch(data, { + operations: [ + {operation: 'Assign', location, params: {value: 'updated'}} + ] + }); + expect(resolvePropPath(data, location)).to.equal('last'); + expect(resolvePropPath(updated, location)).to.equal('updated'); + }); + + it('does not visit unselected siblings', () => { + const value = { + selected: {value: 42}, + get sibling() { + throw new Error('Unselected sibling was accessed'); + } + }; + expect(resolvePropPath(value, ['selected', 'value'])).to.equal(42); + }); + + it('handles malformed paths defensively', () => { + for (const path of ['x', null, {}, [true], [null], [{}]]) { + expect(resolvePropPath({x: [1]}, path)).to.equal(undefined); + } + }); +}); diff --git a/tests/unit/test_websocket_get_prop.py b/tests/unit/test_websocket_get_prop.py new file mode 100644 index 0000000000..9802fbff94 --- /dev/null +++ b/tests/unit/test_websocket_get_prop.py @@ -0,0 +1,171 @@ +"""Partial-read get_prop protocol tests without a browser or server.""" + +import asyncio +import json +import threading +from contextlib import asynccontextmanager + +import janus +import pytest + +from dash.backends.ws import ( + _JS_MAX_SAFE_INTEGER, + DashWebsocketCallback, + _validate_prop_path, +) + + +@asynccontextmanager +async def websocket(threaded=False): + outbound = janus.Queue() + pending = {} + ws = DashWebsocketCallback( + pending, + "renderer", + outbound, + threading.Event(), + None if threaded else asyncio.get_running_loop(), + ) + try: + yield ws, pending, outbound + finally: + outbound.close() + await outbound.wait_closed() + + +def start_read(ws, threaded, *args, **kwargs): + read = ws.get_prop(*args, **kwargs) + if threaded: + read = asyncio.to_thread(asyncio.run, read) + return asyncio.create_task(read) + + +async def next_request(outbound): + return json.loads(await asyncio.wait_for(outbound.async_q.get(), timeout=2)) + + +def respond(pending, message, payload): + waiter = pending[message["requestId"]] + if isinstance(waiter, asyncio.Future): + waiter.set_result(payload) + else: + waiter.put_nowait(payload) + + +@pytest.mark.parametrize( + "path", + [ + None, + [], + ["records", 0, -1], + [_JS_MAX_SAFE_INTEGER], + [-_JS_MAX_SAFE_INTEGER], + ], +) +def test_validate_prop_path_accepts_supported_values(path): + _validate_prop_path(path) + + +@pytest.mark.parametrize("threaded", [False, True]) +@pytest.mark.parametrize( + "options", [{}, {"path": None}, {"path": []}, {"path": ["a", -1, 0]}] +) +def test_get_prop_request(threaded, options): + async def run(): + async with websocket(threaded) as (ws, pending, outbound): + # The third positional argument remains the timeout. + task = start_read(ws, threaded, "store", "data", 2.0, **options) + message = await next_request(outbound) + payload = {"componentId": "store", "properties": ["data"]} + if options.get("path") is not None: + payload["path"] = options["path"] + assert message == { + "type": "get_props_request", + "rendererId": "renderer", + "requestId": message["requestId"], + "payload": payload, + } + respond(pending, message, {"data": 42}) + assert await asyncio.wait_for(task, 2) == 42 + assert not pending + + asyncio.run(run()) + + +@pytest.mark.parametrize("threaded", [False, True]) +@pytest.mark.parametrize("value", [0, False, "", [], {}, None]) +def test_get_prop_preserves_json_values(threaded, value): + async def run(): + async with websocket(threaded) as (ws, pending, outbound): + task = start_read(ws, threaded, "store", "data", path=["selected"]) + message = await next_request(outbound) + respond(pending, message, {"data": value}) + result = await asyncio.wait_for(task, 2) + assert result == value + assert type(result) is type(value) + assert not pending + + asyncio.run(run()) + + +@pytest.mark.parametrize( + "path", + [ + "a.b", + ("a",), + {}, + 0, + True, + [True], + [1.0], + [None], + [[]], + [slice(1)], + ["a", {}], + ], +) +def test_get_prop_invalid_path(path): + async def run(): + async with websocket() as (ws, pending, outbound): + with pytest.raises(TypeError, match="path"): + await ws.get_prop("store", "data", path=path) + assert not pending + assert outbound.sync_q.empty() + + asyncio.run(run()) + + +@pytest.mark.parametrize("index", [2**53, -(2**53)]) +def test_get_prop_unsafe_index(index): + async def run(): + async with websocket() as (ws, pending, outbound): + with pytest.raises(ValueError, match="safe integer"): + await ws.get_prop("store", "data", path=["a", index]) + assert not pending + assert outbound.sync_q.empty() + + asyncio.run(run()) + + +def test_get_prop_path_is_keyword_only(): + async def run(): + async with websocket() as (ws, pending, outbound): + with pytest.raises(TypeError): + await ws.get_prop("store", "data", 2.0, ["a"]) + assert not pending + assert outbound.sync_q.empty() + + asyncio.run(run()) + + +@pytest.mark.parametrize("threaded", [False, True]) +def test_get_prop_path_preserves_timeout_cleanup(threaded): + async def run(): + async with websocket(threaded) as (ws, pending, outbound): + task = start_read(ws, threaded, "store", "data", timeout=0.05, path=[0]) + message = await next_request(outbound) + with pytest.raises(TimeoutError, match="store.data"): + await asyncio.wait_for(task, 2) + assert message["requestId"] not in pending + + asyncio.run(run()) diff --git a/tests/websocket/test_ws_get_prop.py b/tests/websocket/test_ws_get_prop.py new file mode 100644 index 0000000000..178f855fce --- /dev/null +++ b/tests/websocket/test_ws_get_prop.py @@ -0,0 +1,200 @@ +"""WebSocket partial get_prop tests using specially constructed Store data.""" + +import asyncio +import json +import queue + +import pytest + +from dash import Dash, Input, Output, Patch, ctx, dcc, hooks, html, set_props +from dash._utils import stringify_id + + +@pytest.mark.parametrize("backend", ["fastapi", "quart"]) +def test_wsgp001_partial_reads_from_store(dash_duo, backend): + """Read all supported path shapes from one Store without changing it.""" + data = { + "node1": { + "node1-1": {"target": {"value": "event value"}}, + "records": [{"values": [0, False, "", None]}, {"values": [42]}], + }, + "empty_list": [], + "empty_dict": {}, + "a.b[0]": "literal key", + "matrix": [[1, 2], [3, {"value": "nested list"}]], + } + app = Dash(__name__, backend=backend, websocket_callbacks=True) + app.layout = html.Div( + [ + dcc.Store(id="store", data=data), + html.Button("Read", id="read"), + html.Pre(id="result"), + html.Div(id="observed"), + ] + ) + changes = [] + + @app.callback(Output("observed", "children"), Input("store", "data")) + def observe_data(value): + changes.append(value) + return str(len(changes)) + + @app.callback( + Output("result", "children"), + Input("read", "n_clicks"), + prevent_initial_call=True, + ) + async def read_values(_): + ws = ctx.websocket + paths = { + "nested_object": ["node1", "node1-1", "target", "value"], + "list_subtree": ["node1", "records", 1], + "negative_indices": ["node1", "records", -1, "values", -1], + "zero": ["node1", "records", -2, "values", -4], + "false": ["node1", "records", 0, "values", 1], + "empty_string": ["node1", "records", 0, "values", 2], + "null": ["node1", "records", 0, "values", 3], + "missing": ["node1", "missing", "value"], + "empty_list": ["empty_list"], + "empty_dict": ["empty_dict"], + "literal_key": ["a.b[0]"], + "nested_list": ["matrix", -1, -1, "value"], + } + values = await asyncio.gather( + *(ws.get_prop("store", "data", path=path) for path in paths.values()) + ) + result = dict(zip(paths, values)) + result["full"] = await ws.get_prop("store", "data", 5.0) + result["none_path"] = await ws.get_prop("store", "data", path=None) + result["empty_path"] = await ws.get_prop("store", "data", path=[]) + return json.dumps(result, ensure_ascii=False, sort_keys=True) + + expected = { + "nested_object": "event value", + "list_subtree": {"values": [42]}, + "negative_indices": 42, + "zero": 0, + "false": False, + "empty_string": "", + "null": None, + "missing": None, + "empty_list": [], + "empty_dict": {}, + "literal_key": "literal key", + "nested_list": "nested list", + "full": data, + "none_path": data, + "empty_path": data, + } + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#observed", "1") + dash_duo.find_element("#read").click() + dash_duo.wait_for_text_to_equal( + "#result", json.dumps(expected, ensure_ascii=False, sort_keys=True) + ) + assert changes == [data] + assert dash_duo.get_logs() == [] + + +@pytest.mark.parametrize("backend", ["fastapi", "quart"]) +def test_wsgp002_current_state_patch_and_dict_id(dash_duo, backend): + """Read browser edits, Patch updates, dict IDs, and a dynamic Store.""" + component_id = {"type": "store", "index": 0} + app = Dash(__name__, backend=backend, websocket_callbacks=True) + app.layout = html.Div( + [ + dcc.Store(id=component_id, data={"records": [{"value": "initial"}]}), + html.Button("Read", id="read"), + html.Pre(id="result"), + html.Div(id="container"), + ] + ) + + @app.callback( + Output("result", "children"), + Input("read", "n_clicks"), + prevent_initial_call=True, + ) + async def read_callback(_): + ws = ctx.websocket + before = await ws.get_prop( + stringify_id(component_id), "data", path=["records", -1, "value"] + ) + patch = Patch() + patch["records"].append({"value": "patched"}) + set_props(component_id, {"data": patch}) + after = await ws.get_prop( + stringify_id(component_id), "data", path=["records", -1, "value"] + ) + set_props( + "container", + {"children": dcc.Store(id="dynamic", data=[{"value": "dynamic"}])}, + ) + dynamic = await ws.get_prop("dynamic", "data", path=[-1, "value"]) + return json.dumps([before, after, dynamic]) + + dash_duo.start_server(app) + dash_duo.driver.execute_script( + "window.dash_clientside.set_props(arguments[0], " + "{data: {records: [{value: 'browser'}]}})", + component_id, + ) + dash_duo.find_element("#read").click() + dash_duo.wait_for_text_to_equal("#result", '["browser", "patched", "dynamic"]') + assert dash_duo.get_logs() == [] + + +@pytest.mark.parametrize("dev_bundle", [False, True], ids=["production", "development"]) +def test_wsgp003_wire_payload_excludes_unselected_data( + dash_duo, ws_hook_cleanup, dev_bundle +): + """Unselected Store data must not appear in a partial-read response.""" + responses = queue.Queue() + + @hooks.websocket_message() + def capture_response(_websocket, message): + if message.get("type") == "get_props_response": + responses.put(message) + return True + + def data(size): + return {"selected": {"value": 42}, "large_sibling": "UNSELECTED" * size} + + app = Dash(__name__, backend="fastapi", websocket_callbacks=True) + app.layout = html.Div( + [ + dcc.Store(id="store", data=data(10000)), + html.Button("Read", id="read"), + html.Div(id="result"), + ] + ) + + @app.callback( + Output("result", "children"), + Input("read", "n_clicks"), + prevent_initial_call=True, + ) + async def read_callback(_): + for size in [10000, 50000]: + set_props("store", {"data": data(size)}) + value = await ctx.websocket.get_prop( + "store", "data", path=["selected", "value"] + ) + if value != 42: + return f"unexpected: {value}" + return "done" + + dash_duo.start_server(app, dev_tools_serve_dev_bundles=dev_bundle) + dash_duo.find_element("#read").click() + dash_duo.wait_for_text_to_equal("#result", "done") + messages = [responses.get(timeout=2) for _ in range(2)] + assert responses.empty() + assert len({message["requestId"] for message in messages}) == 2 + for message in messages: + assert message["payload"] == {"data": 42} + assert "UNSELECTED" not in json.dumps(message) + assert len(json.dumps(messages[0]["payload"])) == len( + json.dumps(messages[1]["payload"]) + ) + assert dash_duo.get_logs() == []