From fe6b30ac34a4c97e99e9d5362cec64b5952e8c54 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:26:34 -0400 Subject: [PATCH 01/28] allowing callbacks to be exposed as api's by providing a endpoint. --- dash/_callback.py | 16 +++++++++++++++- dash/dash.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/dash/_callback.py b/dash/_callback.py index b0f7bdad5f..4e66351557 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -72,6 +72,7 @@ def is_no_update(obj): GLOBAL_CALLBACK_LIST = [] GLOBAL_CALLBACK_MAP = {} GLOBAL_INLINE_SCRIPTS = [] +GLOBAL_API_PATHS = {} # pylint: disable=too-many-locals @@ -87,6 +88,7 @@ def callback( cache_args_to_ignore: Optional[list] = None, cache_ignore_triggered=True, on_error: Optional[Callable[[Exception], Any]] = None, + api_path: Optional[str] = None, **_kwargs, ) -> Callable[..., Any]: """ @@ -178,6 +180,7 @@ def callback( ) callback_map = _kwargs.pop("callback_map", GLOBAL_CALLBACK_MAP) callback_list = _kwargs.pop("callback_list", GLOBAL_CALLBACK_LIST) + callback_api_paths = _kwargs.pop("callback_api_paths", GLOBAL_API_PATHS) if background: background_spec: Any = { @@ -217,12 +220,14 @@ def callback( callback_list, callback_map, config_prevent_initial_callbacks, + callback_api_paths, *_args, **_kwargs, background=background_spec, manager=manager, running=running, on_error=on_error, + api_path=api_path, ) @@ -585,7 +590,12 @@ def _prepare_response( # pylint: disable=too-many-branches,too-many-statements def register_callback( - callback_list, callback_map, config_prevent_initial_callbacks, *_args, **_kwargs + callback_list, + callback_map, + config_prevent_initial_callbacks, + callback_api_paths, + *_args, + **_kwargs, ): ( output, @@ -638,6 +648,10 @@ def register_callback( # pylint: disable=too-many-locals def wrap_func(func): + if _kwargs.get("api_path"): + api_path = _kwargs.get("api_path") + callback_api_paths[api_path] = func + if background is None: background_key = None else: diff --git a/dash/dash.py b/dash/dash.py index e7b194705e..a54cf94a51 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -568,6 +568,7 @@ def __init__( # pylint: disable=too-many-statements self.callback_map = {} # same deps as a list to catch duplicate outputs, and to send to the front end self._callback_list = [] + self.callback_api_paths = {} # list of inline scripts self._inline_scripts = [] @@ -778,6 +779,42 @@ def _setup_routes(self): # catch-all for front-end routes, used by dcc.Location self._add_url("", self.index) + def setup_apis(self): + # Copy over global callback data structures assigned with `dash.callback` + for k in list(_callback.GLOBAL_API_PATHS): + if k in self.callback_api_paths: + raise DuplicateCallback( + f"The callback `{k}` provided with `dash.callback` was already " + "assigned with `app.callback`." + ) + self.callback_api_paths[k] = _callback.GLOBAL_API_PATHS.pop(k) + + def make_parse_body(func): + def _parse_body(): + if flask.request.is_json: + data = flask.request.get_json() + return flask.jsonify(func(**data)) + return flask.jsonify({}) + + return _parse_body + + def make_parse_body_async(func): + async def _parse_body_async(): + if flask.request.is_json: + data = flask.request.get_json() + result = await func(**data) + return flask.jsonify(result) + return flask.jsonify({}) + + return _parse_body_async + + for path, func in self.callback_api_paths.items(): + print(path) + if asyncio.iscoroutinefunction(func): + self._add_url(path, make_parse_body_async(func), ["POST"]) + else: + self._add_url(path, make_parse_body(func), ["POST"]) + def _setup_plotlyjs(self): # pylint: disable=import-outside-toplevel from plotly.offline import get_plotlyjs_version @@ -1346,6 +1383,7 @@ def callback(self, *_args, **_kwargs) -> Callable[..., Any]: config_prevent_initial_callbacks=self.config.prevent_initial_callbacks, callback_list=self._callback_list, callback_map=self.callback_map, + callback_api_paths=self.callback_api_paths, **_kwargs, ) @@ -1496,6 +1534,7 @@ def dispatch(self): def _setup_server(self): if self._got_first_request["setup_server"]: return + self._got_first_request["setup_server"] = True # Apply _force_eager_loading overrides from modules From d177dcf66ddfde11dbdd6927e1539f4dd703ccd5 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Thu, 26 Jun 2025 20:29:02 -0400 Subject: [PATCH 02/28] change `api_path` -> `api_endpoint` --- dash/_callback.py | 10 +++++----- dash/dash.py | 1 - 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/dash/_callback.py b/dash/_callback.py index 4e66351557..f2343d7f94 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -88,7 +88,7 @@ def callback( cache_args_to_ignore: Optional[list] = None, cache_ignore_triggered=True, on_error: Optional[Callable[[Exception], Any]] = None, - api_path: Optional[str] = None, + api_endpoint: Optional[str] = None, **_kwargs, ) -> Callable[..., Any]: """ @@ -227,7 +227,7 @@ def callback( manager=manager, running=running, on_error=on_error, - api_path=api_path, + api_endpoint=api_endpoint, ) @@ -648,9 +648,9 @@ def register_callback( # pylint: disable=too-many-locals def wrap_func(func): - if _kwargs.get("api_path"): - api_path = _kwargs.get("api_path") - callback_api_paths[api_path] = func + if _kwargs.get("api_endpoint"): + api_endpoint = _kwargs.get("api_endpoint") + callback_api_paths[api_endpoint] = func if background is None: background_key = None diff --git a/dash/dash.py b/dash/dash.py index a54cf94a51..89e75646cd 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -809,7 +809,6 @@ async def _parse_body_async(): return _parse_body_async for path, func in self.callback_api_paths.items(): - print(path) if asyncio.iscoroutinefunction(func): self._add_url(path, make_parse_body_async(func), ["POST"]) else: From 5dde871d0380afcacf94ae9fa3345acb0fdf3543 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 19 Aug 2025 08:34:23 -0400 Subject: [PATCH 03/28] Update CITATION.cff --- CITATION.cff | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 39aea60526..6e9039e94f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,7 +9,7 @@ authors: given-names: "Alex" orcid: https://orcid.org/0000-0003-4623-4147 title: "A data and analytics web app framework for Python, no JavaScript required." -version: 2.18.2 +version: 3.2.0 doi: 10.5281/zenodo.14182630 -date-released: 2024-11-04 +date-released: 2025-07-31 url: https://github.com/plotly/dash From b7fcc0464054e6a8b9b44f54861569df1f06224b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:35:34 +0000 Subject: [PATCH 04/28] Bump sha.js from 2.4.11 to 2.4.12 in /dash/dash-renderer Bumps [sha.js](https://github.com/crypto-browserify/sha.js) from 2.4.11 to 2.4.12. - [Changelog](https://github.com/browserify/sha.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/crypto-browserify/sha.js/compare/v2.4.11...v2.4.12) --- updated-dependencies: - dependency-name: sha.js dependency-version: 2.4.12 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- dash/dash-renderer/package-lock.json | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/dash/dash-renderer/package-lock.json b/dash/dash-renderer/package-lock.json index 4f0e413452..15195f8c38 100644 --- a/dash/dash-renderer/package-lock.json +++ b/dash/dash-renderer/package-lock.json @@ -9439,15 +9439,22 @@ "dev": true }, "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/shallow-clone": { @@ -17849,12 +17856,13 @@ "dev": true }, "sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", "requires": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" } }, "shallow-clone": { From 90fa4c36c51960ace9486438878cd2d6fea38650 Mon Sep 17 00:00:00 2001 From: CNFeffery Date: Tue, 26 Aug 2025 10:58:01 +0800 Subject: [PATCH 05/28] Fix #3366 --- dash/dash-renderer/src/actions/callbacks.ts | 12 ++++- ...multiple_output_return_single_no_update.py | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 tests/integration/clientside/test_clientside_multiple_output_return_single_no_update.py diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 78b2ad1550..8f3c4f97fd 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -226,8 +226,16 @@ function refErr(errors: any, paths: any) { const getVals = (input: any) => Array.isArray(input) ? pluck('value', input) : input.value; -const zipIfArray = (a: any, b: any) => - Array.isArray(a) ? zip(a, b) : [[a, b]]; +const zipIfArray = (a: any, b: any) => { + if (Array.isArray(a)) { + // For client-side callbacks with multiple Outputs, only return a single dash_clientside.no_update + if (b?.description === 'Return to prevent updating an Output.') { + return zip(a, [b]); + } + return zip(a, b); + } + return [[a, b]]; +}; function cleanOutputProp(property: string) { return property.split('@')[0]; diff --git a/tests/integration/clientside/test_clientside_multiple_output_return_single_no_update.py b/tests/integration/clientside/test_clientside_multiple_output_return_single_no_update.py new file mode 100644 index 0000000000..4ba55a2c40 --- /dev/null +++ b/tests/integration/clientside/test_clientside_multiple_output_return_single_no_update.py @@ -0,0 +1,46 @@ +from dash import ( + Dash, + Input, + Output, + html, + clientside_callback, +) + + +def test_cmorsnu001_clientside_multiple_output_return_single_no_update(dash_duo): + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("trigger", id="trigger-demo"), + html.Div("demo1", id="output-demo1"), + html.Div("demo2", id="output-demo2"), + ], + style={"padding": 50}, + ) + + clientside_callback( + """(n_clicks) => { + try { + return window.dash_clientside.no_update; + } catch (e) { + return [null, null]; + } + }""", + Output("output-demo1", "children"), + Output("output-demo2", "children"), + Input("trigger-demo", "n_clicks"), + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + trigger_clicker = dash_duo.wait_for_element("#trigger-demo") + trigger_clicker.click() + dash_duo.wait_for_text_to_equal( + "#output-demo1", + "demo1", + ) + dash_duo.wait_for_text_to_equal( + "#output-demo2", + "demo2", + ) From d339423388771f2889e137bac7ce6770db77ed03 Mon Sep 17 00:00:00 2001 From: CNFeffery Date: Tue, 26 Aug 2025 11:07:50 +0800 Subject: [PATCH 06/28] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9bc7db83b..252898131b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed - [#3395](https://github.com/plotly/dash/pull/3395) Fix Components added through set_props() cannot trigger related callback functions. Fix [#3316](https://github.com/plotly/dash/issues/3316) - [#3397](https://github.com/plotly/dash/pull/3397) Add optional callbacks, suppressing callback warning for missing component ids for a single callback. +- [#3415](https://github.com/plotly/dash/pull/3415) Fix the error triggered when only a single no_update is returned for client-side callback functions with multiple Outputs. Fix [#3366](https://github.com/plotly/dash/issues/3366) ## [3.2.0] - 2025-07-31 From ae473a6ad7a0b109b78e094ace777e05d3d234c1 Mon Sep 17 00:00:00 2001 From: CNFeffery Date: Tue, 26 Aug 2025 21:18:47 +0800 Subject: [PATCH 07/28] Update the judgment conditions for no_update --- dash/dash-renderer/src/actions/callbacks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 8f3c4f97fd..ff677fee94 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -229,7 +229,7 @@ const getVals = (input: any) => const zipIfArray = (a: any, b: any) => { if (Array.isArray(a)) { // For client-side callbacks with multiple Outputs, only return a single dash_clientside.no_update - if (b?.description === 'Return to prevent updating an Output.') { + if (b === (window as any).dash_clientside.no_update) { return zip(a, [b]); } return zip(a, b); From 733e08585105d13b654c46258a06e1076f3920e8 Mon Sep 17 00:00:00 2001 From: Remy Gavard Date: Sun, 31 Aug 2025 16:24:43 +0100 Subject: [PATCH 08/28] fix: migrate Jupyter integration from deprecated ipykernel.comm to comm module - Replace ipykernel.comm.Comm with comm.create_comm - Refactor kernel access to use get_ipython().kernel directly instead of comm.kernel - Resolves DeprecationWarning in Python 3.12+ - Maintains full backward compatibility and functionality - Passes all linting checks (pylint 10/10, flake8 clean, black formatted) The new comm module's DummyComm doesn't provide kernel access like the old ipykernel.comm.Comm. This fix properly migrates to get kernel from IPython directly, which is more robust and follows the intended usage pattern. Fixes #3416 --- CHANGELOG.md | 1 + dash/_jupyter.py | 30 +++++++++++++++++++++--------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 252898131b..7aad387787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3395](https://github.com/plotly/dash/pull/3395) Fix Components added through set_props() cannot trigger related callback functions. Fix [#3316](https://github.com/plotly/dash/issues/3316) - [#3397](https://github.com/plotly/dash/pull/3397) Add optional callbacks, suppressing callback warning for missing component ids for a single callback. - [#3415](https://github.com/plotly/dash/pull/3415) Fix the error triggered when only a single no_update is returned for client-side callback functions with multiple Outputs. Fix [#3366](https://github.com/plotly/dash/issues/3366) +- [#3416](https://github.com/plotly/dash/issues/3416) Fix DeprecationWarning in dash/_jupyter.py by migrating from deprecated ipykernel.comm.Comm to comm module ## [3.2.0] - 2025-07-31 diff --git a/dash/_jupyter.py b/dash/_jupyter.py index ac06f849c7..4211c81164 100644 --- a/dash/_jupyter.py +++ b/dash/_jupyter.py @@ -22,12 +22,12 @@ from IPython.core.display import HTML from IPython.core.ultratb import FormattedTB from retrying import retry - from ipykernel.comm import Comm + from comm import create_comm import nest_asyncio import requests - _dash_comm = Comm(target_name="dash") + _dash_comm = create_comm(target_name="dash") _dep_installed = True except ImportError: _dep_installed = False @@ -97,10 +97,14 @@ def convert(name, locals=locals, formatarg=formatarg, formatvalue=formatvalue): def _send_jupyter_config_comm_request(): # If running in an ipython kernel, # request that the front end extension send us the notebook server base URL - if get_ipython() is not None: - if _dash_comm.kernel is not None: - _caller["parent"] = _dash_comm.kernel.get_parent() - _dash_comm.send({"type": "base_url_request"}) + ipython = get_ipython() + if ( + ipython is not None + and hasattr(ipython, "kernel") + and ipython.kernel is not None + ): + _caller["parent"] = ipython.kernel.get_parent() + _dash_comm.send({"type": "base_url_request"}) def _jupyter_comm_response_received(): @@ -109,7 +113,8 @@ def _jupyter_comm_response_received(): def _request_jupyter_config(timeout=2): # Heavily inspired by implementation of CaptureExecution in the - if _dash_comm.kernel is None: + ipython = get_ipython() + if ipython is None or not hasattr(ipython, "kernel") or ipython.kernel is None: # Not in jupyter setting return @@ -215,8 +220,15 @@ def __init__(self): @_dash_comm.on_msg def _receive_message(msg): prev_parent = _caller.get("parent") - if prev_parent and prev_parent != _dash_comm.kernel.get_parent(): - _dash_comm.kernel.set_parent( + ipython = get_ipython() + if ( + prev_parent + and ipython is not None + and hasattr(ipython, "kernel") + and ipython.kernel is not None + and prev_parent != ipython.kernel.get_parent() + ): + ipython.kernel.set_parent( [prev_parent["header"]["session"]], prev_parent ) del _caller["parent"] From 2a9477e277bb1b41798d455470de1429d2215d85 Mon Sep 17 00:00:00 2001 From: T4rk1n Date: Mon, 1 Sep 2025 00:43:52 +0000 Subject: [PATCH 09/28] Update Dash HTML elements & attributes. --- .../scripts/data/attributes.json | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/components/dash-html-components/scripts/data/attributes.json b/components/dash-html-components/scripts/data/attributes.json index b426737bdb..9c4c6f418e 100644 --- a/components/dash-html-components/scripts/data/attributes.json +++ b/components/dash-html-components/scripts/data/attributes.json @@ -226,11 +226,8 @@ "button", "fieldset", "input", - "label", - "meter", "object", "output", - "progress", "select", "textarea" ], @@ -993,28 +990,13 @@ "target" ], "label": [ - "htmlFor", - "form" + "htmlFor" ], "output": [ "htmlFor", "form", "name" ], - "meter": [ - "form", - "high", - "low", - "max", - "min", - "optimum", - "value" - ], - "progress": [ - "form", - "max", - "value" - ], "canvas": [ "height", "width" @@ -1025,6 +1007,14 @@ "type", "width" ], + "meter": [ + "high", + "low", + "max", + "min", + "optimum", + "value" + ], "base": [ "href", "target" @@ -1035,6 +1025,10 @@ "marquee": [ "loop" ], + "progress": [ + "max", + "value" + ], "source": [ "media", "sizes", From 973a23ca796d9b7115b2ebd6f06a56ef536241e5 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Mon, 1 Sep 2025 14:14:53 -0400 Subject: [PATCH 10/28] brings patch to side-updates and allows for use in clientside (cherry picked from commit 20d930f33745ec8209147c1d10eb1553c799ba26) --- dash/dash-renderer/src/actions/callbacks.ts | 11 +- dash/dash-renderer/src/actions/patch.ts | 160 ++++++++++++++++++ .../src/utils/clientsideFunctions.ts | 14 +- 3 files changed, 181 insertions(+), 4 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index ff677fee94..79960936e7 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -41,7 +41,7 @@ import {createAction, Action} from 'redux-actions'; import {addHttpHeaders} from '../actions'; import {notifyObservers, updateProps} from './index'; import {CallbackJobPayload} from '../reducers/callbackJobs'; -import {handlePatch, isPatch} from './patch'; +import {handlePatch, isPatch, parsePatchProps} from './patch'; import {computePaths, getPath} from './paths'; import {requestDependencies} from './requestDependencies'; @@ -419,7 +419,6 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) { }, [] as any[]) .forEach(([id, idProps]) => { const state = getState(); - dispatch(updateComponent(id, idProps, cb)); const componentPath = getPath(state.paths, id); if (!componentPath) { @@ -429,12 +428,18 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) { } const oldComponent = getComponentLayout(componentPath, state); + const oldProps = oldComponent?.props || {}; + + const patchedProps = parsePatchProps(idProps, oldProps) + + dispatch(updateComponent(id, patchedProps, cb)); + dispatch( setPaths( computePaths( { ...oldComponent, - props: {...oldComponent.props, ...idProps} + props: {...oldComponent.props, ...patchedProps} }, [...componentPath], state.paths, diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index fac5b59ba3..c2f9481ea2 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -44,6 +44,143 @@ function getLocationPath(location: LocationIndex[], obj: any) { return current; } +export class PatchBuilder { + private operations: PatchOperation[] = []; + + assign(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Assign', + location, + params: { value } + }); + return this; + } + + merge(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Merge', + location, + params: { value } + }); + return this; + } + + extend(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Extend', + location, + params: { value } + }); + return this; + } + + delete(location: LocationIndex[]) { + this.operations.push({ + operation: 'Delete', + location, + params: {} + }); + return this; + } + + insert(location: LocationIndex[], index: number, value: any) { + this.operations.push({ + operation: 'Insert', + location, + params: { index, value } + }); + return this; + } + + append(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Append', + location, + params: { value } + }); + return this; + } + + prepend(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Prepend', + location, + params: { value } + }); + return this; + } + + add(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Add', + location, + params: { value } + }); + return this; + } + + sub(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Sub', + location, + params: { value } + }); + return this; + } + + mul(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Mul', + location, + params: { value } + }); + return this; + } + + div(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Div', + location, + params: { value } + }); + return this; + } + + clear(location: LocationIndex[]) { + this.operations.push({ + operation: 'Clear', + location, + params: {} + }); + return this; + } + + reverse(location: LocationIndex[]) { + this.operations.push({ + operation: 'Reverse', + location, + params: {} + }); + return this; + } + + remove(location: LocationIndex[], value: any) { + this.operations.push({ + operation: 'Remove', + location, + params: { value } + }); + return this; + } + + build() { + return { + __dash_patch_update: true, + operations: this.operations + }; + } +} + const patchHandlers: {[k: string]: PatchHandler} = { Assign: (previous, patchOperation) => { const {params, location} = patchOperation; @@ -166,3 +303,26 @@ export function handlePatch(previousValue: T, patchValue: any): T { return reducedValue; } + +export function parsePatchProps(props: any, previousProps: any): {} { + if (!is(Object, props)) { + return props; + } + + let patchedProps: any = {}; + + for (const key of Object.keys(props)) { + const val = props[key]; + if (isPatch(val)) { + const previousValue = previousProps[key]; + if (previousValue === undefined) { + throw new Error('Cannot patch undefined'); + } + patchedProps[key] = handlePatch(previousValue, val); + } else { + patchedProps[key] = val; + } + } + + return patchedProps; +} diff --git a/dash/dash-renderer/src/utils/clientsideFunctions.ts b/dash/dash-renderer/src/utils/clientsideFunctions.ts index f325e34b5c..0c44623fd1 100644 --- a/dash/dash-renderer/src/utils/clientsideFunctions.ts +++ b/dash/dash-renderer/src/utils/clientsideFunctions.ts @@ -1,4 +1,5 @@ import {updateProps, notifyObservers, setPaths} from '../actions/index'; +import {parsePatchProps, PatchBuilder} from '../actions/patch'; import {computePaths, getPath} from '../actions/paths'; import {getComponentLayout} from '../wrapper/wrapping'; import {getStores} from './stores'; @@ -23,6 +24,16 @@ function set_props( } else { componentPath = idOrPath; } + const oldComponent = getComponentLayout(componentPath, state); + if (!oldComponent) { + console.error( + `Could not find component with id or path: ${idOrPath}` + ); + return; + } + // Handle any patch props + props = parsePatchProps(props, oldComponent.props); + dispatch( updateProps({ props, @@ -31,7 +42,7 @@ function set_props( }) ); dispatch(notifyObservers({id: idOrPath, props})); - const oldComponent = getComponentLayout(componentPath, state); + dispatch( setPaths( @@ -77,3 +88,4 @@ const dc = ((window as any).dash_clientside = (window as any).dash_clientside || {}); dc['set_props'] = set_props; dc['clean_url'] = dc['clean_url'] === undefined ? clean_url : dc['clean_url']; +dc['Patch'] = PatchBuilder; From f7764d9fcf038449124eda037e65000f76e2fce6 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Mon, 1 Sep 2025 16:00:33 -0400 Subject: [PATCH 11/28] moving location of component test --- dash/dash-renderer/src/actions/callbacks.ts | 12 +++++++----- .../src/utils/clientsideFunctions.ts | 16 +++++++++------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 79960936e7..d50b84f5db 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -421,11 +421,7 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) { const state = getState(); const componentPath = getPath(state.paths, id); - if (!componentPath) { - // Component doesn't exist, doesn't matter just allow the - // callback to continue. - return; - } + const oldComponent = getComponentLayout(componentPath, state); const oldProps = oldComponent?.props || {}; @@ -434,6 +430,12 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) { dispatch(updateComponent(id, patchedProps, cb)); + if (!componentPath) { + // Component doesn't exist, doesn't matter just allow the + // callback to continue. + return; + } + dispatch( setPaths( computePaths( diff --git a/dash/dash-renderer/src/utils/clientsideFunctions.ts b/dash/dash-renderer/src/utils/clientsideFunctions.ts index 0c44623fd1..ebae9a8f28 100644 --- a/dash/dash-renderer/src/utils/clientsideFunctions.ts +++ b/dash/dash-renderer/src/utils/clientsideFunctions.ts @@ -25,15 +25,11 @@ function set_props( componentPath = idOrPath; } const oldComponent = getComponentLayout(componentPath, state); - if (!oldComponent) { - console.error( - `Could not find component with id or path: ${idOrPath}` - ); - return; - } + // Handle any patch props - props = parsePatchProps(props, oldComponent.props); + props = parsePatchProps(props, oldComponent?.props || {}); + // Update the props dispatch( updateProps({ props, @@ -43,6 +39,12 @@ function set_props( ); dispatch(notifyObservers({id: idOrPath, props})); + if (!oldComponent) { + console.error( + `Could not find component with id or path: ${idOrPath}` + ); + return; + } dispatch( setPaths( From 9bb570b120971d1dae01548597756162a83bce47 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Mon, 1 Sep 2025 16:14:56 -0400 Subject: [PATCH 12/28] adding test and docstring --- dash/_callback.py | 8 +++ .../callbacks/test_api_callback.py | 53 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/integration/callbacks/test_api_callback.py diff --git a/dash/_callback.py b/dash/_callback.py index 96f9af5fa3..4919853c11 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -167,6 +167,14 @@ def callback( Mark all dependencies as not required on the initial layout checks. :param hidden: Hide the callback from the devtools callbacks tab. + :param api_endpoint: + If provided, the callback will be available at the given API endpoint. + This allows you to call the callback directly through HTTP requests + instead of through the Dash front-end. The endpoint should be a string + that starts with a forward slash (e.g. `/my_callback`). + The endpoint is relative to the Dash app's base URL. + Note that the endpoint will not appear in the list of registered + callbacks in the Dash devtools. """ background_spec = None diff --git a/tests/integration/callbacks/test_api_callback.py b/tests/integration/callbacks/test_api_callback.py new file mode 100644 index 0000000000..1988f53a34 --- /dev/null +++ b/tests/integration/callbacks/test_api_callback.py @@ -0,0 +1,53 @@ + +from dash import ( + Dash, + Input, + Output, + html, + ctx, +) +import requests +import json +from flask import jsonify + +test_string = ('{"step_0": "Data fetched - 1", "step_1": "Data fetched - 1", "step_2": "Data fetched - 1", ' + '"step_3": "Data fetched - 1", "step_4": "Data fetched - 1"}') + +def test_apib001_api_callback(dash_duo): + + app = Dash(__name__) + app.layout = html.Div([ + html.Button("Slow Callback", id="slow-btn"), + html.Div(id="slow-output"), + ]) + + def get_data(n_clicks): + # Simulate an async data fetch + return f"Data fetched - {n_clicks}" + + @app.callback( + Output("slow-output", "children"), + Input("slow-btn", "n_clicks"), + prevent_initial_call=True, + api_endpoint='/api/slow_callback', # Example API path for the slow callback + ) + def slow_callback(n_clicks): + data = {} + for i in range(5): + data[f'step_{i}'] = get_data(n_clicks) + ret = f"{json.dumps(data)}" + if ctx: + return ret + return jsonify(ret) + + app.setup_apis() + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#slow-btn").click() + dash_duo.wait_for_text_to_equal("#slow-output", test_string) + r = requests.post(dash_duo.server_url +'/api/slow_callback', + json={'n_clicks': 1}, + headers={'Content-Type': 'application/json'}) + assert r.status_code == 200 + assert r.json() == test_string From 51054a9aac316a13192ef4d98d5938292fcfa851 Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 2 Sep 2025 10:04:12 -0400 Subject: [PATCH 13/28] Remove print in async get_app --- dash/_get_app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dash/_get_app.py b/dash/_get_app.py index a088e593ff..77df6d2219 100644 --- a/dash/_get_app.py +++ b/dash/_get_app.py @@ -23,7 +23,6 @@ def with_app_context_async(func): async def wrap(self, *args, **kwargs): app_context.set(self) ctx = copy_context() - print("copied and set") return await ctx.run(func, self, *args, **kwargs) return wrap From dbfb8d88467fde7652c2176f9cd039cc17e1e122 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:43:02 -0400 Subject: [PATCH 14/28] fixing issue with sideUpdates and not passing the `getComponentLayout` to dispatch adjustments --- dash/dash-renderer/src/actions/callbacks.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index d50b84f5db..74967bfe39 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -421,8 +421,10 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) { const state = getState(); const componentPath = getPath(state.paths, id); - - const oldComponent = getComponentLayout(componentPath, state); + let oldComponent = null; + if (componentPath) { + oldComponent = getComponentLayout(componentPath, state) + } const oldProps = oldComponent?.props || {}; From 9d369b6668737b71c09fd46a7ef367dcfc32ff23 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:50:44 -0400 Subject: [PATCH 15/28] fixing for lint --- dash/dash-renderer/src/actions/callbacks.ts | 6 ++--- dash/dash-renderer/src/actions/patch.ts | 24 +++++++++---------- .../src/utils/clientsideFunctions.ts | 6 ++--- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 74967bfe39..b8cb4b6b8c 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -421,14 +421,14 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) { const state = getState(); const componentPath = getPath(state.paths, id); - let oldComponent = null; + let oldComponent = {}; if (componentPath) { - oldComponent = getComponentLayout(componentPath, state) + oldComponent = getComponentLayout(componentPath, state); } const oldProps = oldComponent?.props || {}; - const patchedProps = parsePatchProps(idProps, oldProps) + const patchedProps = parsePatchProps(idProps, oldProps); dispatch(updateComponent(id, patchedProps, cb)); diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index c2f9481ea2..1380589282 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -51,7 +51,7 @@ export class PatchBuilder { this.operations.push({ operation: 'Assign', location, - params: { value } + params: {value} }); return this; } @@ -60,7 +60,7 @@ export class PatchBuilder { this.operations.push({ operation: 'Merge', location, - params: { value } + params: {value} }); return this; } @@ -69,7 +69,7 @@ export class PatchBuilder { this.operations.push({ operation: 'Extend', location, - params: { value } + params: {value} }); return this; } @@ -87,7 +87,7 @@ export class PatchBuilder { this.operations.push({ operation: 'Insert', location, - params: { index, value } + params: {index, value} }); return this; } @@ -96,7 +96,7 @@ export class PatchBuilder { this.operations.push({ operation: 'Append', location, - params: { value } + params: {value} }); return this; } @@ -105,7 +105,7 @@ export class PatchBuilder { this.operations.push({ operation: 'Prepend', location, - params: { value } + params: {value} }); return this; } @@ -114,7 +114,7 @@ export class PatchBuilder { this.operations.push({ operation: 'Add', location, - params: { value } + params: {value} }); return this; } @@ -123,7 +123,7 @@ export class PatchBuilder { this.operations.push({ operation: 'Sub', location, - params: { value } + params: {value} }); return this; } @@ -132,7 +132,7 @@ export class PatchBuilder { this.operations.push({ operation: 'Mul', location, - params: { value } + params: {value} }); return this; } @@ -141,7 +141,7 @@ export class PatchBuilder { this.operations.push({ operation: 'Div', location, - params: { value } + params: {value} }); return this; } @@ -168,7 +168,7 @@ export class PatchBuilder { this.operations.push({ operation: 'Remove', location, - params: { value } + params: {value} }); return this; } @@ -309,7 +309,7 @@ export function parsePatchProps(props: any, previousProps: any): {} { return props; } - let patchedProps: any = {}; + const patchedProps: any = {}; for (const key of Object.keys(props)) { const val = props[key]; diff --git a/dash/dash-renderer/src/utils/clientsideFunctions.ts b/dash/dash-renderer/src/utils/clientsideFunctions.ts index ebae9a8f28..c8d50d6b4c 100644 --- a/dash/dash-renderer/src/utils/clientsideFunctions.ts +++ b/dash/dash-renderer/src/utils/clientsideFunctions.ts @@ -40,9 +40,9 @@ function set_props( dispatch(notifyObservers({id: idOrPath, props})); if (!oldComponent) { - console.error( - `Could not find component with id or path: ${idOrPath}` - ); + // console.error( + // `Could not find component with id or path: ${idOrPath}` + // ); return; } From a0beaa4699de0dc767c6782aef72d5e7c1240a63 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:58:37 -0400 Subject: [PATCH 16/28] fix for lint --- dash/dash-renderer/src/actions/callbacks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index b8cb4b6b8c..0042715a47 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -421,7 +421,7 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) { const state = getState(); const componentPath = getPath(state.paths, id); - let oldComponent = {}; + let oldComponent = {props: {}}; if (componentPath) { oldComponent = getComponentLayout(componentPath, state); } From d2e1ea1dac3c2e86839f139abd3c92958b950fcc Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:27:34 -0400 Subject: [PATCH 17/28] adding tests for side updates --- dash/dash-renderer/src/actions/callbacks.ts | 33 +- dash/dash-renderer/src/actions/patch.ts | 4 +- tests/integration/test_clientside_patch.py | 656 ++++++++++++++++++++ tests/integration/test_patch.py | 328 +++++++++- 4 files changed, 995 insertions(+), 26 deletions(-) create mode 100644 tests/integration/test_clientside_patch.py diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 0042715a47..8154c1ca2e 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -41,7 +41,7 @@ import {createAction, Action} from 'redux-actions'; import {addHttpHeaders} from '../actions'; import {notifyObservers, updateProps} from './index'; import {CallbackJobPayload} from '../reducers/callbackJobs'; -import {handlePatch, isPatch, parsePatchProps} from './patch'; +import {parsePatchProps} from './patch'; import {computePaths, getPath} from './paths'; import {requestDependencies} from './requestDependencies'; @@ -818,12 +818,23 @@ export function executeCallback( if (clientside_function) { try { - const data = await handleClientside( + let data = await handleClientside( dispatch, clientside_function, config, payload ); + // Patch methodology: always run through parsePatchProps for each output + const currentLayout = getState().layout; + flatten(outputs).forEach((out: any) => { + const propName = cleanOutputProp(out.property); + const outputPath = getPath(paths, out.id); + const dataPath = [stringifyId(out.id), propName]; + const outputValue = path(dataPath, data); + const oldProps = path(outputPath.concat(['props']), currentLayout) || {}; + const newProps = parsePatchProps({ [propName]: outputValue }, oldProps); + data = assocPath(dataPath, newProps[propName], data); + }); return {data, payload}; } catch (error: any) { return {error, payload}; @@ -882,26 +893,16 @@ export function executeCallback( dispatch(addHttpHeaders(newHeaders)); } // Layout may have changed. + // DRY: Always run through parsePatchProps for each output const currentLayout = getState().layout; flatten(outputs).forEach((out: any) => { const propName = cleanOutputProp(out.property); const outputPath = getPath(paths, out.id); - const previousValue = path( - outputPath.concat(['props', propName]), - currentLayout - ); const dataPath = [stringifyId(out.id), propName]; const outputValue = path(dataPath, data); - if (isPatch(outputValue)) { - if (previousValue === undefined) { - throw new Error('Cannot patch undefined'); - } - data = assocPath( - dataPath, - handlePatch(previousValue, outputValue), - data - ); - } + const oldProps = path(outputPath.concat(['props']), currentLayout) || {}; + const newProps = parsePatchProps({ [propName]: outputValue }, oldProps); + data = assocPath(dataPath, newProps[propName], data); }); if (dynamic_creator) { diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index 1380589282..bbcb252781 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -175,7 +175,7 @@ export class PatchBuilder { build() { return { - __dash_patch_update: true, + __dash_patch_update: "__dash_patch_update", operations: this.operations }; } @@ -304,7 +304,7 @@ export function handlePatch(previousValue: T, patchValue: any): T { return reducedValue; } -export function parsePatchProps(props: any, previousProps: any): {} { +export function parsePatchProps(props: any, previousProps: any): Record { if (!is(Object, props)) { return props; } diff --git a/tests/integration/test_clientside_patch.py b/tests/integration/test_clientside_patch.py new file mode 100644 index 0000000000..88e480f884 --- /dev/null +++ b/tests/integration/test_clientside_patch.py @@ -0,0 +1,656 @@ +import json + +import flaky + +from selenium.webdriver.common.keys import Keys + +from dash import Dash, html, dcc, Input, Output, State, ALL, Patch +from dash.testing.wait import until + + +@flaky.flaky(max_runs=3) +def test_pch_cs001_patch_operations_clientside(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Div([dcc.Input(id="set-value"), html.Button("Set", id="set-btn")]), + html.Div([dcc.Input(id="append-value"), html.Button("Append", id="append-btn")]), + html.Div([dcc.Input(id="prepend-value"), html.Button("prepend", id="prepend-btn")]), + html.Div([ + dcc.Input(id="insert-value"), + dcc.Input(id="insert-index", type="number", value=1), + html.Button("insert", id="insert-btn"), + ]), + html.Div([dcc.Input(id="extend-value"), html.Button("extend", id="extend-btn")]), + html.Div([dcc.Input(id="merge-value"), html.Button("Merge", id="merge-btn")]), + html.Button("Delete", id="delete-btn"), + html.Button("Delete index", id="delete-index"), + html.Button("Clear", id="clear-btn"), + html.Button("Reverse", id="reverse-btn"), + html.Button("Remove", id="remove-btn"), + dcc.Store( + data={ + "value": "unset", + "n_clicks": 0, + "array": ["initial"], + "delete": "Delete me", + }, + id="store", + ), + html.Div(id="store-content"), + ] + ) + + app.clientside_callback( + "function a(value) {return JSON.stringify(value)}", + Output("store-content", "children"), + Input("store", "data"), + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + return patch + .assign(["value"], value) + .add(["n_clicks"], 1) + .build(); + } + """, + Output("store", "data"), + Input("set-btn", "n_clicks"), + State("set-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + return patch + .append(["array"], value) + .add(["n_clicks"], 1) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("append-btn", "n_clicks"), + State("append-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + return patch + .prepend(["array"], value) + .add(["n_clicks"], 1) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("prepend-btn", "n_clicks"), + State("prepend-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + return patch + .extend(["array"], [value]) + .add(["n_clicks"], 1) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("extend-btn", "n_clicks"), + State("extend-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + return patch + .merge([], {merged: value}) + .add(["n_clicks"], 1) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("merge-btn", "n_clicks"), + State("merge-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + return patch + .delete(["delete"]) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("delete-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value, index) { + const patch = new dash_clientside.Patch + return patch + .insert(["array"], index, value) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("insert-btn", "n_clicks"), + State("insert-value", "value"), + State("insert-index", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + return patch + .delete(["array", 1]) + .delete(["array", -2]) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("delete-index", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + return patch + .clear(["array"]) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("clear-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + return patch + .reverse(["array"]) + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("reverse-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + return patch + .remove(["array"], "initial") + .build(); + } + """, + Output("store", "data", allow_duplicate=True), + Input("remove-btn", "n_clicks"), + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + assert dash_duo.get_logs() == [] + + def get_output(): + e = dash_duo.find_element("#store-content") + return json.loads(e.text) + + _input = dash_duo.find_element("#set-value") + _input.send_keys("Set Value") + dash_duo.find_element("#set-btn").click() + + until(lambda: get_output().get('value') == "Set Value", 2) + + _input = dash_duo.find_element("#append-value") + _input.send_keys("Append") + dash_duo.find_element("#append-btn").click() + + until(lambda: get_output().get("array") == ["initial", "Append"], 2) + + _input = dash_duo.find_element("#prepend-value") + _input.send_keys("Prepend") + dash_duo.find_element("#prepend-btn").click() + + until(lambda: get_output().get("array") == ["Prepend", "initial", "Append"], 2) + + _input = dash_duo.find_element("#extend-value") + _input.send_keys("Extend") + dash_duo.find_element("#extend-btn").click() + + until( + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], 2 + ) + + undef = object() + until(lambda: get_output().get("merged", undef) is undef, 2) + + _input = dash_duo.find_element("#merge-value") + _input.send_keys("Merged") + dash_duo.find_element("#merge-btn").click() + + until(lambda: get_output().get("merged") == "Merged", 2) + + until(lambda: get_output().get("delete") == "Delete me", 2) + + dash_duo.find_element("#delete-btn").click() + + until(lambda: get_output().get("delete", undef) is undef, 2) + + _input = dash_duo.find_element("#insert-value") + _input.send_keys("Inserted") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Extend", + ], + 2, + ) + + _input.send_keys(" with negative index") + _input = dash_duo.find_element("#insert-index") + _input.send_keys(Keys.BACKSPACE) + _input.send_keys("-1") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Inserted with negative index", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#delete-index").click() + until( + lambda: get_output().get("array") + == [ + "Prepend", + "initial", + "Append", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#reverse-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "initial", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#remove-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#clear-btn").click() + until(lambda: get_output()["array"] == [], 2) + +@flaky.flaky(max_runs=3) +def test_pch_cs002_patch_operations_set_props(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Div([dcc.Input(id="set-value"), html.Button("Set", id="set-btn")]), + html.Div([dcc.Input(id="append-value"), html.Button("Append", id="append-btn")]), + html.Div([dcc.Input(id="prepend-value"), html.Button("prepend", id="prepend-btn")]), + html.Div([ + dcc.Input(id="insert-value"), + dcc.Input(id="insert-index", type="number", value=1), + html.Button("insert", id="insert-btn"), + ]), + html.Div([dcc.Input(id="extend-value"), html.Button("extend", id="extend-btn")]), + html.Div([dcc.Input(id="merge-value"), html.Button("Merge", id="merge-btn")]), + html.Button("Delete", id="delete-btn"), + html.Button("Delete index", id="delete-index"), + html.Button("Clear", id="clear-btn"), + html.Button("Reverse", id="reverse-btn"), + html.Button("Remove", id="remove-btn"), + dcc.Store( + data={ + "value": "unset", + "n_clicks": 0, + "array": ["initial"], + "delete": "Delete me", + }, + id="store", + ), + html.Div(id="store-content"), + ] + ) + + app.clientside_callback( + "function a(value) {return JSON.stringify(value)}", + Output("store-content", "children"), + Input("store", "data"), + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .assign(["value"], value) + .add(["n_clicks"], 1) + .build()}); + } + """, + Input("set-btn", "n_clicks"), + State("set-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .append(["array"], value) + .add(["n_clicks"], 1) + .build()}); + } + """, + Input("append-btn", "n_clicks"), + State("append-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .prepend(["array"], value) + .add(["n_clicks"], 1) + .build()}); + } + """, + Input("prepend-btn", "n_clicks"), + State("prepend-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .extend(["array"], [value]) + .add(["n_clicks"], 1) + .build()}); + } + """, + Input("extend-btn", "n_clicks"), + State("extend-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .merge([], {merged: value}) + .add(["n_clicks"], 1) + .build()}); + } + """, + Input("merge-btn", "n_clicks"), + State("merge-value", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .delete(["delete"]) + .build()}); + } + """, + Input("delete-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks, value, index) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .insert(["array"], index, value) + .build()}); + } + """, + Input("insert-btn", "n_clicks"), + State("insert-value", "value"), + State("insert-index", "value"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .delete(["array", 1]) + .delete(["array", -2]) + .build()}); + } + """, + Input("delete-index", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .clear(["array"]) + .build()}); + } + """, + Input("clear-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .reverse(["array"]) + .build()}); + } + """, + Input("reverse-btn", "n_clicks"), + prevent_initial_call=True, + ) + + app.clientside_callback( + """ + function a(n_clicks) { + const patch = new dash_clientside.Patch + dash_clientside.set_props('store', {data: patch + .remove(["array"], "initial") + .build()}); + } + """, + Input("remove-btn", "n_clicks"), + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + assert dash_duo.get_logs() == [] + + def get_output(): + e = dash_duo.find_element("#store-content") + return json.loads(e.text) + + _input = dash_duo.find_element("#set-value") + _input.send_keys("Set Value") + dash_duo.find_element("#set-btn").click() + + until(lambda: get_output().get('value') == "Set Value", 2) + + _input = dash_duo.find_element("#append-value") + _input.send_keys("Append") + dash_duo.find_element("#append-btn").click() + + until(lambda: get_output().get("array") == ["initial", "Append"], 2) + + _input = dash_duo.find_element("#prepend-value") + _input.send_keys("Prepend") + dash_duo.find_element("#prepend-btn").click() + + until(lambda: get_output().get("array") == ["Prepend", "initial", "Append"], 2) + + _input = dash_duo.find_element("#extend-value") + _input.send_keys("Extend") + dash_duo.find_element("#extend-btn").click() + + until( + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], 2 + ) + + undef = object() + until(lambda: get_output().get("merged", undef) is undef, 2) + + _input = dash_duo.find_element("#merge-value") + _input.send_keys("Merged") + dash_duo.find_element("#merge-btn").click() + + until(lambda: get_output().get("merged") == "Merged", 2) + + until(lambda: get_output().get("delete") == "Delete me", 2) + + dash_duo.find_element("#delete-btn").click() + + until(lambda: get_output().get("delete", undef) is undef, 2) + + _input = dash_duo.find_element("#insert-value") + _input.send_keys("Inserted") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Extend", + ], + 2, + ) + + _input.send_keys(" with negative index") + _input = dash_duo.find_element("#insert-index") + _input.send_keys(Keys.BACKSPACE) + _input.send_keys("-1") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Inserted with negative index", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#delete-index").click() + until( + lambda: get_output().get("array") + == [ + "Prepend", + "initial", + "Append", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#reverse-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "initial", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#remove-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#clear-btn").click() + until(lambda: get_output()["array"] == [], 2) diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index c2b3b3f500..49a9859a5e 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -4,7 +4,7 @@ from selenium.webdriver.common.keys import Keys -from dash import Dash, html, dcc, Input, Output, State, ALL, Patch +from dash import Dash, html, dcc, Input, Output, State, ALL, Patch, set_props from dash.testing.wait import until @@ -220,38 +220,38 @@ def get_output(): _input.send_keys("Set Value") dash_duo.find_element("#set-btn").click() - until(lambda: get_output()["value"] == "Set Value", 2) + until(lambda: get_output().get("value") == "Set Value", 2) _input = dash_duo.find_element("#append-value") _input.send_keys("Append") dash_duo.find_element("#append-btn").click() - until(lambda: get_output()["array"] == ["initial", "Append"], 2) + until(lambda: get_output().get("array") == ["initial", "Append"], 2) _input = dash_duo.find_element("#prepend-value") _input.send_keys("Prepend") dash_duo.find_element("#prepend-btn").click() - until(lambda: get_output()["array"] == ["Prepend", "initial", "Append"], 2) + until(lambda: get_output().get("array") == ["Prepend", "initial", "Append"], 2) _input = dash_duo.find_element("#extend-value") _input.send_keys("Extend") dash_duo.find_element("#extend-btn").click() until( - lambda: get_output()["array"] == ["Prepend", "initial", "Append", "Extend"], 2 + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], 2 ) undef = object() - until(lambda: get_output().get("merge", undef) is undef, 2) + until(lambda: get_output().get("merged", undef) is undef, 2) _input = dash_duo.find_element("#merge-value") _input.send_keys("Merged") dash_duo.find_element("#merge-btn").click() - until(lambda: get_output()["merged"] == "Merged", 2) + until(lambda: get_output().get("merged") == "Merged", 2) - until(lambda: get_output()["delete"] == "Delete me", 2) + until(lambda: get_output().get("delete") == "Delete me", 2) dash_duo.find_element("#delete-btn").click() @@ -570,3 +570,315 @@ def on_merge(_): dash_duo.wait_for_text_to_equal( "#dict-store-output", '{"initial":"initial","merged":"merged"}' ) + +@flaky.flaky(max_runs=3) +def test_pch007_patch_operations_side_updates(dash_duo): + + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Div( + [ + dcc.Input(id="set-value"), + html.Button("Set", id="set-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="append-value"), + html.Button("Append", id="append-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="prepend-value"), + html.Button("prepend", id="prepend-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="insert-value"), + dcc.Input(id="insert-index", type="number", value=1), + html.Button("insert", id="insert-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="extend-value"), + html.Button("extend", id="extend-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="merge-value"), + html.Button("Merge", id="merge-btn"), + ] + ), + html.Button("Delete", id="delete-btn"), + html.Button("Delete index", id="delete-index"), + html.Button("Clear", id="clear-btn"), + html.Button("Reverse", id="reverse-btn"), + html.Button("Remove", id="remove-btn"), + dcc.Store( + data={ + "value": "unset", + "n_clicks": 0, + "array": ["initial"], + "delete": "Delete me", + }, + id="store", + ), + html.Div(id="store-content"), + ] + ) + + app.clientside_callback( + "function(value) {return JSON.stringify(value)}", + Output("store-content", "children"), + Input("store", "data"), + ) + + @app.callback( + + Input("set-btn", "n_clicks"), + State("set-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.value = value + p.n_clicks += 1 + + set_props('store', {'data': p}) + + @app.callback( + Input("append-btn", "n_clicks"), + State("append-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.array.append(value) + p.n_clicks += 1 + + set_props('store', {'data': p}) + + @app.callback( + Input("prepend-btn", "n_clicks"), + State("prepend-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.array.prepend(value) + p.n_clicks += 1 + + set_props('store', {'data': p}) + + @app.callback( + Input("extend-btn", "n_clicks"), + State("extend-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.array.extend([value]) + p.n_clicks += 1 + + set_props('store', {'data': p}) + + @app.callback( + Input("merge-btn", "n_clicks"), + State("merge-value", "value"), + prevent_initial_call=True, + ) + def on_click(_, value): + p = Patch() + p.update({"merged": value}) + p.n_clicks += 1 + + set_props('store', {'data': p}) + + @app.callback( + Input("delete-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(_): + p = Patch() + del p.delete + set_props('store', {'data': p}) + + @app.callback( + Input("insert-btn", "n_clicks"), + State("insert-value", "value"), + State("insert-index", "value"), + prevent_initial_call=True, + ) + def on_insert(_, value, index): + p = Patch() + p.array.insert(index, value) + + set_props('store', {'data': p}) + + @app.callback( + Input("delete-index", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(_): + p = Patch() + del p.array[1] + del p.array[-2] + + set_props('store', {'data': p}) + + @app.callback( + Input("clear-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_clear(_): + p = Patch() + p.array.clear() + + set_props('store', {'data': p}) + + @app.callback( + Input("reverse-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_reverse(_): + p = Patch() + p.array.reverse() + + set_props('store', {'data': p}) + + @app.callback( + Input("remove-btn", "n_clicks"), + prevent_initial_call=True, + ) + def on_remove(_): + p = Patch() + p.array.remove("initial") + set_props('store', {'data': p}) + + dash_duo.start_server(app) + + assert dash_duo.get_logs() == [] + + def get_output(): + e = dash_duo.find_element("#store-content") + return json.loads(e.text) + + _input = dash_duo.find_element("#set-value") + _input.send_keys("Set Value") + dash_duo.find_element("#set-btn").click() + + until(lambda: get_output().get("value") == "Set Value", 2) + + _input = dash_duo.find_element("#append-value") + _input.send_keys("Append") + dash_duo.find_element("#append-btn").click() + + until(lambda: get_output().get("array") == ["initial", "Append"], 2) + + _input = dash_duo.find_element("#prepend-value") + _input.send_keys("Prepend") + dash_duo.find_element("#prepend-btn").click() + + until(lambda: get_output().get("array") == ["Prepend", "initial", "Append"], 2) + + _input = dash_duo.find_element("#extend-value") + _input.send_keys("Extend") + dash_duo.find_element("#extend-btn").click() + + until( + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], 2 + ) + + undef = object() + until(lambda: get_output().get("merged", undef) is undef, 2) + + _input = dash_duo.find_element("#merge-value") + _input.send_keys("Merged") + dash_duo.find_element("#merge-btn").click() + + until(lambda: get_output().get("merged") == "Merged", 2) + + until(lambda: get_output().get("delete") == "Delete me", 2) + + dash_duo.find_element("#delete-btn").click() + + until(lambda: get_output().get("delete", undef) is undef, 2) + + _input = dash_duo.find_element("#insert-value") + _input.send_keys("Inserted") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Extend", + ], + 2, + ) + + _input.send_keys(" with negative index") + _input = dash_duo.find_element("#insert-index") + _input.send_keys(Keys.BACKSPACE) + _input.send_keys("-1") + dash_duo.find_element("#insert-btn").click() + + until( + lambda: get_output().get("array") + == [ + "Prepend", + "Inserted", + "initial", + "Append", + "Inserted with negative index", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#delete-index").click() + until( + lambda: get_output().get("array") + == [ + "Prepend", + "initial", + "Append", + "Extend", + ], + 2, + ) + + dash_duo.find_element("#reverse-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "initial", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#remove-btn").click() + until( + lambda: get_output().get("array") + == [ + "Extend", + "Append", + "Prepend", + ], + 2, + ) + + dash_duo.find_element("#clear-btn").click() + until(lambda: get_output()["array"] == [], 2) From 4919d8996322f452f0af956c0519bf46fe2eba60 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:30:22 -0400 Subject: [PATCH 18/28] adding changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 252898131b..a212358a2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3395](https://github.com/plotly/dash/pull/3396) Add position argument to hooks.devtool - [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes. - [#3407](https://github.com/plotly/dash/pull/3407) Add `hidden` to callback arguments, hiding the callback from appearing in the devtool callback graph. +- [#3424](https://github.com/plotly/dash/pull/3424) Adds support for `Patch` on clientside callbacks class `dash_clientside.Patch`, as well as supporting side updates, eg: (Running, SetProps). ## Fixed - [#3395](https://github.com/plotly/dash/pull/3395) Fix Components added through set_props() cannot trigger related callback functions. Fix [#3316](https://github.com/plotly/dash/issues/3316) From a157deb46926f5e3e1c319f30b6bc84d25a6ab02 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Tue, 2 Sep 2025 17:24:59 -0400 Subject: [PATCH 19/28] fixing issue with side-updates from background callbacks --- dash/dash-renderer/src/actions/callbacks.ts | 38 +++++++++-- dash/dash-renderer/src/actions/patch.ts | 7 +- tests/integration/test_clientside_patch.py | 73 ++++++++++++++------- tests/integration/test_patch.py | 30 +++++---- 4 files changed, 103 insertions(+), 45 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 8154c1ca2e..8ee88d5c6a 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -623,7 +623,9 @@ function handleServerside( } if (data.sideUpdate) { - dispatch(sideUpdate(data.sideUpdate, payload)); + setTimeout(() => + dispatch(sideUpdate(data.sideUpdate, payload)) + ); // always force side-updates } if (data.progress) { @@ -831,9 +833,20 @@ export function executeCallback( const outputPath = getPath(paths, out.id); const dataPath = [stringifyId(out.id), propName]; const outputValue = path(dataPath, data); - const oldProps = path(outputPath.concat(['props']), currentLayout) || {}; - const newProps = parsePatchProps({ [propName]: outputValue }, oldProps); - data = assocPath(dataPath, newProps[propName], data); + const oldProps = + path( + outputPath.concat(['props']), + currentLayout + ) || {}; + const newProps = parsePatchProps( + {[propName]: outputValue}, + oldProps + ); + data = assocPath( + dataPath, + newProps[propName], + data + ); }); return {data, payload}; } catch (error: any) { @@ -900,9 +913,20 @@ export function executeCallback( const outputPath = getPath(paths, out.id); const dataPath = [stringifyId(out.id), propName]; const outputValue = path(dataPath, data); - const oldProps = path(outputPath.concat(['props']), currentLayout) || {}; - const newProps = parsePatchProps({ [propName]: outputValue }, oldProps); - data = assocPath(dataPath, newProps[propName], data); + const oldProps = + path( + outputPath.concat(['props']), + currentLayout + ) || {}; + const newProps = parsePatchProps( + {[propName]: outputValue}, + oldProps + ); + data = assocPath( + dataPath, + newProps[propName], + data + ); }); if (dynamic_creator) { diff --git a/dash/dash-renderer/src/actions/patch.ts b/dash/dash-renderer/src/actions/patch.ts index bbcb252781..3c3e14d204 100644 --- a/dash/dash-renderer/src/actions/patch.ts +++ b/dash/dash-renderer/src/actions/patch.ts @@ -175,7 +175,7 @@ export class PatchBuilder { build() { return { - __dash_patch_update: "__dash_patch_update", + __dash_patch_update: '__dash_patch_update', operations: this.operations }; } @@ -304,7 +304,10 @@ export function handlePatch(previousValue: T, patchValue: any): T { return reducedValue; } -export function parsePatchProps(props: any, previousProps: any): Record { +export function parsePatchProps( + props: any, + previousProps: any +): Record { if (!is(Object, props)) { return props; } diff --git a/tests/integration/test_clientside_patch.py b/tests/integration/test_clientside_patch.py index 88e480f884..20b4029eeb 100644 --- a/tests/integration/test_clientside_patch.py +++ b/tests/integration/test_clientside_patch.py @@ -15,15 +15,28 @@ def test_pch_cs001_patch_operations_clientside(dash_duo): app.layout = html.Div( [ html.Div([dcc.Input(id="set-value"), html.Button("Set", id="set-btn")]), - html.Div([dcc.Input(id="append-value"), html.Button("Append", id="append-btn")]), - html.Div([dcc.Input(id="prepend-value"), html.Button("prepend", id="prepend-btn")]), - html.Div([ - dcc.Input(id="insert-value"), - dcc.Input(id="insert-index", type="number", value=1), - html.Button("insert", id="insert-btn"), - ]), - html.Div([dcc.Input(id="extend-value"), html.Button("extend", id="extend-btn")]), - html.Div([dcc.Input(id="merge-value"), html.Button("Merge", id="merge-btn")]), + html.Div( + [dcc.Input(id="append-value"), html.Button("Append", id="append-btn")] + ), + html.Div( + [ + dcc.Input(id="prepend-value"), + html.Button("prepend", id="prepend-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="insert-value"), + dcc.Input(id="insert-index", type="number", value=1), + html.Button("insert", id="insert-btn"), + ] + ), + html.Div( + [dcc.Input(id="extend-value"), html.Button("extend", id="extend-btn")] + ), + html.Div( + [dcc.Input(id="merge-value"), html.Button("Merge", id="merge-btn")] + ), html.Button("Delete", id="delete-btn"), html.Button("Delete index", id="delete-index"), html.Button("Clear", id="clear-btn"), @@ -227,7 +240,7 @@ def get_output(): _input.send_keys("Set Value") dash_duo.find_element("#set-btn").click() - until(lambda: get_output().get('value') == "Set Value", 2) + until(lambda: get_output().get("value") == "Set Value", 2) _input = dash_duo.find_element("#append-value") _input.send_keys("Append") @@ -246,7 +259,8 @@ def get_output(): dash_duo.find_element("#extend-btn").click() until( - lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], 2 + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], + 2, ) undef = object() @@ -337,6 +351,7 @@ def get_output(): dash_duo.find_element("#clear-btn").click() until(lambda: get_output()["array"] == [], 2) + @flaky.flaky(max_runs=3) def test_pch_cs002_patch_operations_set_props(dash_duo): app = Dash(__name__) @@ -344,15 +359,28 @@ def test_pch_cs002_patch_operations_set_props(dash_duo): app.layout = html.Div( [ html.Div([dcc.Input(id="set-value"), html.Button("Set", id="set-btn")]), - html.Div([dcc.Input(id="append-value"), html.Button("Append", id="append-btn")]), - html.Div([dcc.Input(id="prepend-value"), html.Button("prepend", id="prepend-btn")]), - html.Div([ - dcc.Input(id="insert-value"), - dcc.Input(id="insert-index", type="number", value=1), - html.Button("insert", id="insert-btn"), - ]), - html.Div([dcc.Input(id="extend-value"), html.Button("extend", id="extend-btn")]), - html.Div([dcc.Input(id="merge-value"), html.Button("Merge", id="merge-btn")]), + html.Div( + [dcc.Input(id="append-value"), html.Button("Append", id="append-btn")] + ), + html.Div( + [ + dcc.Input(id="prepend-value"), + html.Button("prepend", id="prepend-btn"), + ] + ), + html.Div( + [ + dcc.Input(id="insert-value"), + dcc.Input(id="insert-index", type="number", value=1), + html.Button("insert", id="insert-btn"), + ] + ), + html.Div( + [dcc.Input(id="extend-value"), html.Button("extend", id="extend-btn")] + ), + html.Div( + [dcc.Input(id="merge-value"), html.Button("Merge", id="merge-btn")] + ), html.Button("Delete", id="delete-btn"), html.Button("Delete index", id="delete-index"), html.Button("Clear", id="clear-btn"), @@ -545,7 +573,7 @@ def get_output(): _input.send_keys("Set Value") dash_duo.find_element("#set-btn").click() - until(lambda: get_output().get('value') == "Set Value", 2) + until(lambda: get_output().get("value") == "Set Value", 2) _input = dash_duo.find_element("#append-value") _input.send_keys("Append") @@ -564,7 +592,8 @@ def get_output(): dash_duo.find_element("#extend-btn").click() until( - lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], 2 + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], + 2, ) undef = object() diff --git a/tests/integration/test_patch.py b/tests/integration/test_patch.py index 49a9859a5e..28cfd09f9b 100644 --- a/tests/integration/test_patch.py +++ b/tests/integration/test_patch.py @@ -239,7 +239,8 @@ def get_output(): dash_duo.find_element("#extend-btn").click() until( - lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], 2 + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], + 2, ) undef = object() @@ -571,6 +572,7 @@ def on_merge(_): "#dict-store-output", '{"initial":"initial","merged":"merged"}' ) + @flaky.flaky(max_runs=3) def test_pch007_patch_operations_side_updates(dash_duo): @@ -640,7 +642,6 @@ def test_pch007_patch_operations_side_updates(dash_duo): ) @app.callback( - Input("set-btn", "n_clicks"), State("set-value", "value"), prevent_initial_call=True, @@ -650,7 +651,7 @@ def on_click(_, value): p.value = value p.n_clicks += 1 - set_props('store', {'data': p}) + set_props("store", {"data": p}) @app.callback( Input("append-btn", "n_clicks"), @@ -662,7 +663,7 @@ def on_click(_, value): p.array.append(value) p.n_clicks += 1 - set_props('store', {'data': p}) + set_props("store", {"data": p}) @app.callback( Input("prepend-btn", "n_clicks"), @@ -674,7 +675,7 @@ def on_click(_, value): p.array.prepend(value) p.n_clicks += 1 - set_props('store', {'data': p}) + set_props("store", {"data": p}) @app.callback( Input("extend-btn", "n_clicks"), @@ -686,7 +687,7 @@ def on_click(_, value): p.array.extend([value]) p.n_clicks += 1 - set_props('store', {'data': p}) + set_props("store", {"data": p}) @app.callback( Input("merge-btn", "n_clicks"), @@ -698,7 +699,7 @@ def on_click(_, value): p.update({"merged": value}) p.n_clicks += 1 - set_props('store', {'data': p}) + set_props("store", {"data": p}) @app.callback( Input("delete-btn", "n_clicks"), @@ -707,7 +708,7 @@ def on_click(_, value): def on_click(_): p = Patch() del p.delete - set_props('store', {'data': p}) + set_props("store", {"data": p}) @app.callback( Input("insert-btn", "n_clicks"), @@ -719,7 +720,7 @@ def on_insert(_, value, index): p = Patch() p.array.insert(index, value) - set_props('store', {'data': p}) + set_props("store", {"data": p}) @app.callback( Input("delete-index", "n_clicks"), @@ -730,7 +731,7 @@ def on_click(_): del p.array[1] del p.array[-2] - set_props('store', {'data': p}) + set_props("store", {"data": p}) @app.callback( Input("clear-btn", "n_clicks"), @@ -740,7 +741,7 @@ def on_clear(_): p = Patch() p.array.clear() - set_props('store', {'data': p}) + set_props("store", {"data": p}) @app.callback( Input("reverse-btn", "n_clicks"), @@ -750,7 +751,7 @@ def on_reverse(_): p = Patch() p.array.reverse() - set_props('store', {'data': p}) + set_props("store", {"data": p}) @app.callback( Input("remove-btn", "n_clicks"), @@ -759,7 +760,7 @@ def on_reverse(_): def on_remove(_): p = Patch() p.array.remove("initial") - set_props('store', {'data': p}) + set_props("store", {"data": p}) dash_duo.start_server(app) @@ -792,7 +793,8 @@ def get_output(): dash_duo.find_element("#extend-btn").click() until( - lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], 2 + lambda: get_output().get("array") == ["Prepend", "initial", "Append", "Extend"], + 2, ) undef = object() From d541e379b5a83d2815690045ceeb80276d0485d9 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:18:36 -0400 Subject: [PATCH 20/28] adjustments for `outputValue===undefined` eg (`no_update`) --- dash/dash-renderer/src/actions/callbacks.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 8ee88d5c6a..790c930875 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -623,9 +623,7 @@ function handleServerside( } if (data.sideUpdate) { - setTimeout(() => - dispatch(sideUpdate(data.sideUpdate, payload)) - ); // always force side-updates + dispatch(sideUpdate(data.sideUpdate, payload)); } if (data.progress) { @@ -913,6 +911,9 @@ export function executeCallback( const outputPath = getPath(paths, out.id); const dataPath = [stringifyId(out.id), propName]; const outputValue = path(dataPath, data); + if (outputValue === undefined) { + return; + } const oldProps = path( outputPath.concat(['props']), @@ -922,6 +923,7 @@ export function executeCallback( {[propName]: outputValue}, oldProps ); + data = assocPath( dataPath, newProps[propName], From 3bacb8c1aa89c6ba8c2756ab6a0006db89a5f693 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:52:42 -0400 Subject: [PATCH 21/28] adjusting flaky test for typing speed --- tests/async_tests/test_async_callbacks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/async_tests/test_async_callbacks.py b/tests/async_tests/test_async_callbacks.py index 13cb8418f9..4bd3fd0abd 100644 --- a/tests/async_tests/test_async_callbacks.py +++ b/tests/async_tests/test_async_callbacks.py @@ -376,6 +376,7 @@ async def set_path(n): dash_duo.wait_for_text_to_equal("#out", '[{"a": "/2:a"}] - /2') +@flaky.flaky(max_runs=3) def test_async_cbsc008_wildcard_prop_callbacks(dash_duo): if not is_dash_async(): return @@ -384,7 +385,7 @@ def test_async_cbsc008_wildcard_prop_callbacks(dash_duo): app = Dash(__name__) app.layout = html.Div( [ - dcc.Input(id="input", value="initial value"), + dcc.Input(id="input", value="initial value", debounce=False), html.Div( html.Div( [ @@ -427,6 +428,7 @@ async def update_text(data): for key in "hello world": with lock: input1.send_keys(key) + time.sleep(0.05) # allow some time for debounced callback to be sent dash_duo.wait_for_text_to_equal("#output-1", "hello world") assert dash_duo.find_element("#output-1").get_attribute("data-cb") == "hello world" From 9c59ab5a86e9d97cbe15f4155367dedf6c5b9a3d Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Wed, 3 Sep 2025 13:07:54 -0400 Subject: [PATCH 22/28] fixing for lint --- tests/integration/test_clientside_patch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_clientside_patch.py b/tests/integration/test_clientside_patch.py index 20b4029eeb..7a41c520ee 100644 --- a/tests/integration/test_clientside_patch.py +++ b/tests/integration/test_clientside_patch.py @@ -4,7 +4,7 @@ from selenium.webdriver.common.keys import Keys -from dash import Dash, html, dcc, Input, Output, State, ALL, Patch +from dash import Dash, html, dcc, Input, Output, State from dash.testing.wait import until From fb5b890efc552d2b89d388dd65521c98d0818426 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:08:33 -0400 Subject: [PATCH 23/28] fixing clientside parsing to be the same as serverside --- dash/dash-renderer/src/actions/callbacks.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index 790c930875..ee3a251378 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -831,6 +831,9 @@ export function executeCallback( const outputPath = getPath(paths, out.id); const dataPath = [stringifyId(out.id), propName]; const outputValue = path(dataPath, data); + if (outputValue === undefined) { + return; + } const oldProps = path( outputPath.concat(['props']), From 3b8512c4d12f32573c08a7043cef4741ff1f2799 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Thu, 4 Sep 2025 12:43:31 -0400 Subject: [PATCH 24/28] fixing for lint --- .../callbacks/test_api_callback.py | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/integration/callbacks/test_api_callback.py b/tests/integration/callbacks/test_api_callback.py index 1988f53a34..be06936c15 100644 --- a/tests/integration/callbacks/test_api_callback.py +++ b/tests/integration/callbacks/test_api_callback.py @@ -1,4 +1,3 @@ - from dash import ( Dash, Input, @@ -10,16 +9,21 @@ import json from flask import jsonify -test_string = ('{"step_0": "Data fetched - 1", "step_1": "Data fetched - 1", "step_2": "Data fetched - 1", ' - '"step_3": "Data fetched - 1", "step_4": "Data fetched - 1"}') +test_string = ( + '{"step_0": "Data fetched - 1", "step_1": "Data fetched - 1", "step_2": "Data fetched - 1", ' + '"step_3": "Data fetched - 1", "step_4": "Data fetched - 1"}' +) + def test_apib001_api_callback(dash_duo): app = Dash(__name__) - app.layout = html.Div([ - html.Button("Slow Callback", id="slow-btn"), - html.Div(id="slow-output"), - ]) + app.layout = html.Div( + [ + html.Button("Slow Callback", id="slow-btn"), + html.Div(id="slow-output"), + ] + ) def get_data(n_clicks): # Simulate an async data fetch @@ -29,12 +33,12 @@ def get_data(n_clicks): Output("slow-output", "children"), Input("slow-btn", "n_clicks"), prevent_initial_call=True, - api_endpoint='/api/slow_callback', # Example API path for the slow callback + api_endpoint="/api/slow_callback", # Example API path for the slow callback ) def slow_callback(n_clicks): data = {} for i in range(5): - data[f'step_{i}'] = get_data(n_clicks) + data[f"step_{i}"] = get_data(n_clicks) ret = f"{json.dumps(data)}" if ctx: return ret @@ -46,8 +50,10 @@ def slow_callback(n_clicks): dash_duo.wait_for_element("#slow-btn").click() dash_duo.wait_for_text_to_equal("#slow-output", test_string) - r = requests.post(dash_duo.server_url +'/api/slow_callback', - json={'n_clicks': 1}, - headers={'Content-Type': 'application/json'}) + r = requests.post( + dash_duo.server_url + "/api/slow_callback", + json={"n_clicks": 1}, + headers={"Content-Type": "application/json"}, + ) assert r.status_code == 200 assert r.json() == test_string From c42d732cea23e8e4d43ef67917ccf351b66cbb9f Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Thu, 4 Sep 2025 16:05:18 -0400 Subject: [PATCH 25/28] removing commented out console error --- dash/dash-renderer/src/utils/clientsideFunctions.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/dash/dash-renderer/src/utils/clientsideFunctions.ts b/dash/dash-renderer/src/utils/clientsideFunctions.ts index c8d50d6b4c..4ca3076cac 100644 --- a/dash/dash-renderer/src/utils/clientsideFunctions.ts +++ b/dash/dash-renderer/src/utils/clientsideFunctions.ts @@ -40,9 +40,6 @@ function set_props( dispatch(notifyObservers({id: idOrPath, props})); if (!oldComponent) { - // console.error( - // `Could not find component with id or path: ${idOrPath}` - // ); return; } From ed91bd651747c20f2a34e0bb51faa7344da97fb0 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:02:04 -0400 Subject: [PATCH 26/28] Adjustment to only use the `GLOBAL_API_PATHS` --- dash/_callback.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dash/_callback.py b/dash/_callback.py index 4919853c11..aacb8dbdde 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -184,7 +184,6 @@ def callback( ) callback_map = _kwargs.pop("callback_map", GLOBAL_CALLBACK_MAP) callback_list = _kwargs.pop("callback_list", GLOBAL_CALLBACK_LIST) - callback_api_paths = _kwargs.pop("callback_api_paths", GLOBAL_API_PATHS) if background: background_spec: Any = { @@ -224,7 +223,6 @@ def callback( callback_list, callback_map, config_prevent_initial_callbacks, - callback_api_paths, *_args, **_kwargs, background=background_spec, @@ -603,7 +601,6 @@ def register_callback( callback_list, callback_map, config_prevent_initial_callbacks, - callback_api_paths, *_args, **_kwargs, ): @@ -662,7 +659,7 @@ def register_callback( def wrap_func(func): if _kwargs.get("api_endpoint"): api_endpoint = _kwargs.get("api_endpoint") - callback_api_paths[api_endpoint] = func + GLOBAL_API_PATHS[api_endpoint] = func if background is None: background_key = None From 7835b3354b79540b68979fe5090e96d07c2bc92d Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:07:45 -0400 Subject: [PATCH 27/28] adding better doc string --- dash/dash.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/dash/dash.py b/dash/dash.py index 94bca67eb0..8430259c27 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -785,7 +785,20 @@ def _setup_routes(self): self._add_url("", self.index) def setup_apis(self): - # Copy over global callback data structures assigned with `dash.callback` + """ + Register API endpoints for all callbacks defined using `dash.callback`. + + This method must be called after all callbacks are registered and before the app is served. + It ensures that all callback API routes are available for the Dash app to function correctly. + + Typical usage: + app = Dash(__name__) + # Register callbacks here + app.setup_apis() + app.run() + + If not called, callback endpoints will not be available and the app will not function as expected. + """ for k in list(_callback.GLOBAL_API_PATHS): if k in self.callback_api_paths: raise DuplicateCallback( From 52d5ffdb33c50ddddcb372f6577ff1d6ae8422d3 Mon Sep 17 00:00:00 2001 From: BSd3v <82055130+BSd3v@users.noreply.github.com> Date: Mon, 8 Sep 2025 16:38:32 -0400 Subject: [PATCH 28/28] adding changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aad387787..a6a2224907 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3395](https://github.com/plotly/dash/pull/3396) Add position argument to hooks.devtool - [#3403](https://github.com/plotly/dash/pull/3403) Add app_context to get_app, allowing to get the current app in routes. - [#3407](https://github.com/plotly/dash/pull/3407) Add `hidden` to callback arguments, hiding the callback from appearing in the devtool callback graph. +- [#3347](https://github.com/plotly/dash/pull/3347) Added 'api_endpoint' to `callback` to expose api endpoints at the provided path for use to be executed directly without dash. ## Fixed - [#3395](https://github.com/plotly/dash/pull/3395) Fix Components added through set_props() cannot trigger related callback functions. Fix [#3316](https://github.com/plotly/dash/issues/3316)