diff --git a/CHANGELOG.md b/CHANGELOG.md index 592d7050d6..06feda2166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## [UNRELEASED] + +## 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) + ## [3.2.0] - 2025-07-31 ## Added diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index e77bf803aa..78b2ad1550 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -36,13 +36,13 @@ import { } from '../types/callbacks'; import {isMultiValued, stringifyId, isMultiOutputProp} from './dependencies'; import {urlBase} from './utils'; -import {getCSRFHeader, dispatchError} from '.'; +import {getCSRFHeader, dispatchError, setPaths} from '.'; 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 {getPath} from './paths'; +import {computePaths, getPath} from './paths'; import {requestDependencies} from './requestDependencies'; @@ -51,6 +51,7 @@ import {loadLibrary} from '../utils/libraries'; import {parsePMCId} from './patternMatching'; import {replacePMC} from './patternMatching'; import {loaded, loading} from './loading'; +import {getComponentLayout} from '../wrapper/wrapping'; export const addBlockedCallbacks = createAction( CallbackActionType.AddBlocked @@ -409,7 +410,30 @@ function sideUpdate(outputs: SideUpdateOutput, cb: ICallbackPayload) { return acc; }, [] as any[]) .forEach(([id, idProps]) => { + const state = getState(); dispatch(updateComponent(id, idProps, cb)); + + 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); + + dispatch( + setPaths( + computePaths( + { + ...oldComponent, + props: {...oldComponent.props, ...idProps} + }, + [...componentPath], + state.paths, + state.paths.events + ) + ) + ); }); }; } diff --git a/dash/dash-renderer/src/utils/clientsideFunctions.ts b/dash/dash-renderer/src/utils/clientsideFunctions.ts index 4859df41bf..f325e34b5c 100644 --- a/dash/dash-renderer/src/utils/clientsideFunctions.ts +++ b/dash/dash-renderer/src/utils/clientsideFunctions.ts @@ -1,5 +1,6 @@ -import {updateProps, notifyObservers} from '../actions/index'; -import {getPath} from '../actions/paths'; +import {updateProps, notifyObservers, setPaths} from '../actions/index'; +import {computePaths, getPath} from '../actions/paths'; +import {getComponentLayout} from '../wrapper/wrapping'; import {getStores} from './stores'; /** @@ -16,9 +17,9 @@ function set_props( for (let y = 0; y < ds.length; y++) { const {dispatch, getState} = ds[y]; let componentPath; - const {paths} = getState(); + const state = getState(); if (!Array.isArray(idOrPath)) { - componentPath = getPath(paths, idOrPath); + componentPath = getPath(state.paths, idOrPath); } else { componentPath = idOrPath; } @@ -30,6 +31,21 @@ function set_props( }) ); dispatch(notifyObservers({id: idOrPath, props})); + const oldComponent = getComponentLayout(componentPath, state); + + dispatch( + setPaths( + computePaths( + { + ...oldComponent, + props: {...oldComponent.props, ...props} + }, + [...componentPath], + state.paths, + state.paths.events + ) + ) + ); } } diff --git a/tests/integration/callbacks/test_arbitrary_callbacks.py b/tests/integration/callbacks/test_arbitrary_callbacks.py index f07afc08be..d6038fd7e7 100644 --- a/tests/integration/callbacks/test_arbitrary_callbacks.py +++ b/tests/integration/callbacks/test_arbitrary_callbacks.py @@ -232,3 +232,44 @@ def test_arb007_clientside_no_output(dash_duo): dash_duo.wait_for_text_to_equal("#output", "start1") dash_duo.find_element("#start2").click() dash_duo.wait_for_text_to_equal("#output", "start2") + + +def test_arb008_set_props_chain_cb(dash_duo): + app = Dash(suppress_callback_exceptions=True) + + app.layout = html.Div( + [ + html.Button("origin button", id="origin-button"), + html.Div(id="generated-button-container"), + html.Div("initial text", id="generated-button-output"), + ], + style={"padding": 50}, + ) + + @app.callback( + Input("origin-button", "n_clicks"), + ) + def generate_button(n_clicks): + set_props( + "generated-button-container", + { + "children": html.Button( + "generated button", id="generated-button", n_clicks=0 + ) + }, + ) + + @app.callback( + Output("generated-button-output", "children"), + Input("generated-button", "n_clicks", allow_optional=True), + prevent_initial_call=True, + ) + def update_output(n_clicks): + return f"n_clicks: {n_clicks}" + + dash_duo.start_server(app) + + dash_duo.wait_for_element("#origin-button").click() + for i in range(1, 5): + dash_duo.wait_for_element("#generated-button").click() + dash_duo.wait_for_text_to_equal("#generated-button-output", f"n_clicks: {i}")