Skip to content
Open
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
21 changes: 20 additions & 1 deletion .ai/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions @plotly/dash-websocket-worker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export interface GetPropsRequestMessage extends WorkerMessage {
payload: {
componentId: string;
properties: string[];
/** Optional location within each requested property. */
path?: (string | number)[];
};
}

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
44 changes: 40 additions & 4 deletions dash/backends/ws.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -147,9 +164,14 @@
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,

Check warning on line 170 in dash/backends/ws.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this "timeout" parameter and use a timeout context manager instead.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaB5zD2JZGOzalTr9Tet&open=AaB5zD2JZGOzalTr9Tet&pullRequest=3977
*,
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
Expand All @@ -160,11 +182,16 @@
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.
"""
Expand All @@ -175,12 +202,21 @@
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:
Expand Down
48 changes: 35 additions & 13 deletions dash/dash-renderer/src/observers/websocketObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -40,6 +41,33 @@
return componentId;
}

function readRequestedProps(
componentProps: Record<string, unknown> | undefined,
properties: string[],
propPath: (string | number)[] | undefined
): Record<string, unknown> {
const result: Record<string, unknown> =
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.
*
Expand Down Expand Up @@ -152,31 +180,25 @@
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<string, unknown> = {};
let componentProps: Record<string, unknown> | undefined;

if (componentPath) {
const componentProps = path(
[...componentPath, 'props'],
state.layout
) as Record<string, unknown> | undefined;

if (componentProps) {
for (const propName of properties) {
result[propName] = componentProps[propName];
}
}
componentProps = path([...componentPath, 'props'], state.layout) as
| Record<string, unknown>
| undefined;
} else {
console.warn(
`GET_PROPS_REQUEST: Component ${componentId} not found in layout`
);
}

// Send the response
const result = readRequestedProps(componentProps, properties, propPath);
workerClient.sendGetPropsResponse(requestId, result);
};

Expand Down Expand Up @@ -267,9 +289,9 @@
try {
// config.websocket is guaranteed to exist due to wsAvailable check above
await workerClient.connect(
config.websocket!.worker_url,

Check warning on line 292 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.12)

Forbidden non-null assertion

Check warning on line 292 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.9)

Forbidden non-null assertion
wsUrl,
config.websocket!.inactivity_timeout

Check warning on line 294 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.12)

Forbidden non-null assertion

Check warning on line 294 in dash/dash-renderer/src/observers/websocketObserver.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.9)

Forbidden non-null assertion
);
} catch (error) {
console.error('[Dash] Failed to connect to WebSocket worker:', error);
Expand Down
51 changes: 51 additions & 0 deletions dash/dash-renderer/src/utils/propPath.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)[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;
}
2 changes: 2 additions & 0 deletions dash/dash-renderer/src/utils/workerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
export interface GetPropsRequestPayload {
componentId: string;
properties: string[];
/** Optional location within each requested property. */
path?: (string | number)[];
}

/** Pending callback request */
Expand Down Expand Up @@ -220,7 +222,7 @@
return new Promise((resolve, reject) => {
this.pendingCallbacks.set(requestId, {resolve, reject});

this.worker!.port.postMessage({

Check warning on line 225 in dash/dash-renderer/src/utils/workerClient.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.12)

Forbidden non-null assertion

Check warning on line 225 in dash/dash-renderer/src/utils/workerClient.ts

View workflow job for this annotation

GitHub Actions / Lint & Unit Tests (Python 3.9)

Forbidden non-null assertion
type: WorkerMessageType.CALLBACK_REQUEST,
rendererId: this.rendererId,
requestId,
Expand Down
114 changes: 114 additions & 0 deletions dash/dash-renderer/tests/propPath.test.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
Loading
Loading