diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aaf8a9d9f..8c90904613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed +- [#2429](https://github.com/plotly/dash/pull/2429) Fix side effect on updating possible array children triggering callbacks, fix [#2411](https://github.com/plotly/dash/issues/2411). - [#2417](https://github.com/plotly/dash/pull/2417) Disable the pytest plugin if `dash[testing]` not installed, fix [#946](https://github.com/plotly/dash/issues/946). - [#2417](https://github.com/plotly/dash/pull/2417) Do not swallow the original error to get the webdriver, easier to know what is wrong after updating the browser but the driver. - [#2425](https://github.com/plotly/dash/pull/2425) Fix multiple log handler added unconditionally to the logger, resulting in duplicate log message. diff --git a/dash/dash-renderer/src/actions/dependencies_ts.ts b/dash/dash-renderer/src/actions/dependencies_ts.ts index 50c342f5c7..84a79f64d4 100644 --- a/dash/dash-renderer/src/actions/dependencies_ts.ts +++ b/dash/dash-renderer/src/actions/dependencies_ts.ts @@ -1,16 +1,19 @@ import { all, + any, assoc, concat, difference, filter, flatten, forEach, + includes, isEmpty, keys, map, mergeWith, partition, + path, pickBy, props, reduce, @@ -304,6 +307,24 @@ export const getLayoutCallbacks = ( ); } + if (options.filterRoot) { + let rootId = path(['props', 'id'], layout); + if (rootId) { + rootId = stringifyId(rootId); + // Filter inputs that are not present in the response + callbacks = callbacks.filter(cb => + any( + (inp: any) => + !( + stringifyId(inp.id) === rootId && + !includes(inp.property, options.filterRoot) + ), + cb.callback.inputs + ) + ); + } + } + /* Return all callbacks with an `executionGroup` to allow group-processing */ diff --git a/dash/dash-renderer/src/observers/executedCallbacks.ts b/dash/dash-renderer/src/observers/executedCallbacks.ts index dd1dbcc69d..f3c42e101f 100644 --- a/dash/dash-renderer/src/observers/executedCallbacks.ts +++ b/dash/dash-renderer/src/observers/executedCallbacks.ts @@ -138,7 +138,8 @@ const observer: IStoreObserverDefinition = { const handlePaths = ( children: any, oldChildren: any, - oldChildrenPath: any[] + oldChildrenPath: any[], + filterRoot: any = false ) => { const oPaths = getState().paths; const paths = computePaths( @@ -152,7 +153,8 @@ const observer: IStoreObserverDefinition = { requestedCallbacks = concat( requestedCallbacks, getLayoutCallbacks(graphs, paths, children, { - chunkPath: oldChildrenPath + chunkPath: oldChildrenPath, + filterRoot }).map(rcb => ({ ...rcb, predecessors @@ -166,7 +168,8 @@ const observer: IStoreObserverDefinition = { getLayoutCallbacks(graphs, oldPaths, oldChildren, { removedArrayInputsOnly: true, newPaths: paths, - chunkPath: oldChildrenPath + chunkPath: oldChildrenPath, + filterRoot }).map(rcb => ({ ...rcb, predecessors @@ -204,7 +207,8 @@ const observer: IStoreObserverDefinition = { } }, oldObj, - basePath + basePath, + keys(appliedProps) ); // Only do it once for the component. recomputed = true; diff --git a/tests/integration/renderer/test_component_as_prop.py b/tests/integration/renderer/test_component_as_prop.py index 315f194739..9abb3a813b 100644 --- a/tests/integration/renderer/test_component_as_prop.py +++ b/tests/integration/renderer/test_component_as_prop.py @@ -258,3 +258,38 @@ def demo(n_clicks): dash_duo.wait_for_text_to_equal(f"#options label:nth-child({i}) span", "") dash_duo.wait_for_element(f"#options label:nth-child({i}) button").click() dash_duo.wait_for_text_to_equal(f"#options label:nth-child({i}) span", "1") + + +def test_rdcap003_side_effect_regression(dash_duo): + # Test for #2411, regression introduced by original rdcap002 fix + # callback on the same components that is output with same id but not property triggered + # on cap components of array type like Checklist.options[] and Dropdown.options[]. + app = Dash(__name__) + + app.layout = Div([Button("3<->2", id="a"), Checklist(id="b"), Div(0, id="counter")]) + + app.clientside_callback( + "function(_, prev) {return parseInt(prev) + 1}", + Output("counter", "children"), + Input("b", "value"), + State("counter", "children"), + prevent_initial_call=True, + ) + + @app.callback(Output("b", "options"), Input("a", "n_clicks")) + def opts(n): + n_out = 3 - (n or 0) % 2 + return [str(i) for i in range(n_out)] + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#counter", "0") + dash_duo.find_element("#a").click() + assert len(dash_duo.find_elements("#b label > input")) == 2 + dash_duo.wait_for_text_to_equal("#counter", "0") + dash_duo.find_element("#a").click() + assert len(dash_duo.find_elements("#b label > input")) == 3 + dash_duo.wait_for_text_to_equal("#counter", "0") + + dash_duo.find_elements("#b label > input")[0].click() + dash_duo.wait_for_text_to_equal("#counter", "1")