diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b41ad382..020cb95459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#3264](https://github.com/plotly/dash/pull/3264) Fixed an issue where moving components inside of children would not update the `setProps` path, leading to hashes being incorrect - [#3265](https://github.com/plotly/dash/pull/3265) Fixed issue where the resize of graphs was cancelling others +## Added +- [#3268](https://github.com/plotly/dash/pull/3268) Added the ability for component devs to subscribe to descendent updates by setting `dashChildrenUpdate = true` on the component, eg: `Tabs.dashChildrenUpdate = true` ## [3.0.2] - 2025-04-01 diff --git a/components/dash-core-components/src/components/Tabs.react.js b/components/dash-core-components/src/components/Tabs.react.js index a365925bdb..18acbe08c4 100644 --- a/components/dash-core-components/src/components/Tabs.react.js +++ b/components/dash-core-components/src/components/Tabs.react.js @@ -121,14 +121,6 @@ const EnhancedTab = ({ ); }; -EnhancedTab.defaultProps = { - loading_state: { - is_loading: false, - component_name: '', - prop_name: '', - }, -}; - /** * A Dash component that lets you render pages with tabs - the Tabs component's children * can be dcc.Tab components, which can hold a label that will be displayed as a tab, and can in turn hold @@ -439,3 +431,5 @@ Tabs.propTypes = { */ persistence_type: PropTypes.oneOf(['local', 'session', 'memory']), }; + +Tabs.dashChildrenUpdate = true; diff --git a/dash/dash-renderer/src/wrapper/selectors.ts b/dash/dash-renderer/src/wrapper/selectors.ts index 46930bc47e..b07713ed79 100644 --- a/dash/dash-renderer/src/wrapper/selectors.ts +++ b/dash/dash-renderer/src/wrapper/selectors.ts @@ -1,8 +1,85 @@ import {DashLayoutPath, DashComponent, BaseDashProps} from '../types/component'; -import {getComponentLayout, stringifyPath} from './wrapping'; +import { + getComponentLayout, + stringifyPath, + checkDashChildrenUpdate +} from './wrapping'; +import {pathOr} from 'ramda'; type SelectDashProps = [DashComponent, BaseDashProps, number, object, string]; +interface ChangedPropsRecord { + hash: number; + changedProps: Record; + renderType: string; +} + +interface Hashes { + [key: string]: any; // Index signature for string keys with number values +} + +const previousHashes: Hashes = {}; + +const isFirstLevelPropsChild = ( + updatedPath: string, + strPath: string +): [boolean, string[]] => { + const updatedSegments = updatedPath.split(','); + const fullSegments = strPath.split(','); + + // Check that strPath actually starts with updatedPath + const startsWithPath = fullSegments.every( + (seg, i) => updatedSegments[i] === seg + ); + + if (!startsWithPath) return [false, []]; + + // Get the remaining path after the prefix + const remainingSegments = updatedSegments.slice(fullSegments.length); + + const propsCount = remainingSegments.filter(s => s === 'props').length; + + return [propsCount < 2, remainingSegments]; +}; + +function determineChangedProps( + state: any, + strPath: string +): ChangedPropsRecord { + let combinedHash = 0; + let renderType: any; // Default render type, adjust as needed + const changedProps: Record = {}; + Object.entries(state.layoutHashes).forEach(([updatedPath, pathHash]) => { + const [descendant, remainingSegments] = isFirstLevelPropsChild( + updatedPath, + strPath + ); + if (descendant) { + const previousHash: any = pathOr({}, [updatedPath], previousHashes); + combinedHash += pathOr(0, ['hash'], pathHash); + if (previousHash !== pathHash) { + if (updatedPath !== strPath) { + Object.assign(changedProps, {[remainingSegments[1]]: true}); + renderType = 'components'; + } else { + Object.assign( + changedProps, + pathOr({}, ['changedProps'], pathHash) + ); + renderType = pathOr({}, ['renderType'], pathHash); + } + previousHashes[updatedPath] = pathHash; + } + } + }); + + return { + hash: combinedHash, + changedProps, + renderType + }; +} + export const selectDashProps = (componentPath: DashLayoutPath) => (state: any): SelectDashProps => { @@ -12,7 +89,12 @@ export const selectDashProps = // Then it can be easily compared without having to compare the props. const strPath = stringifyPath(componentPath); - const hash = state.layoutHashes[strPath]; + let hash; + if (checkDashChildrenUpdate(c)) { + hash = determineChangedProps(state, strPath); + } else { + hash = state.layoutHashes[strPath]; + } let h = 0; let changedProps: object = {}; let renderType = ''; diff --git a/dash/dash-renderer/src/wrapper/wrapping.ts b/dash/dash-renderer/src/wrapper/wrapping.ts index 37d2c461ea..6a443990b6 100644 --- a/dash/dash-renderer/src/wrapper/wrapping.ts +++ b/dash/dash-renderer/src/wrapper/wrapping.ts @@ -72,3 +72,14 @@ export function checkRenderTypeProp(componentDefinition: any) { ) ); } + +export function checkDashChildrenUpdate(componentDefinition: any) { + return ( + 'dashChildrenUpdate' in + pathOr( + {}, + [componentDefinition?.namespace, componentDefinition?.type], + window as any + ) + ); +} diff --git a/tests/integration/renderer/test_descendant_listening.py b/tests/integration/renderer/test_descendant_listening.py new file mode 100644 index 0000000000..8bede0866e --- /dev/null +++ b/tests/integration/renderer/test_descendant_listening.py @@ -0,0 +1,55 @@ +from dash import dcc, html, Input, Output, Patch, Dash + + +def test_dcl001_descendant_tabs(dash_duo): + app = Dash() + + app.layout = html.Div( + [ + html.Button("Enable Tabs", id="button", n_clicks=0), + html.Button("Add Tabs", id="add_button", n_clicks=0), + dcc.Store(id="store-data", data=None), + dcc.Tabs( + [ + dcc.Tab(label="Tab A", value="tab-a", id="tab-a", disabled=True), + dcc.Tab(label="Tab B", value="tab-b", id="tab-b", disabled=True), + ], + id="tabs", + value="tab-a", + ), + ] + ) + + @app.callback(Output("store-data", "data"), Input("button", "n_clicks")) + def update_store_data(clicks): + if clicks > 0: + return {"data": "available"} + return None + + @app.callback( + Output("tabs", "children"), + Input("add_button", "n_clicks"), + prevent_initial_call=True, + ) + def add_tabs(n): + children = Patch() + children.append(dcc.Tab(label=f"{n}", value=f"{n}", id=f"test-{n}")) + return children + + @app.callback( + Output("tab-a", "disabled"), + Output("tab-b", "disabled"), + Input("store-data", "data"), + ) + def toggle_tabs(store_data): + if store_data is not None and "data" in store_data: + return False, False + return True, True + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#button", "Enable Tabs") + dash_duo.find_element("#tab-a.tab--disabled") + dash_duo.find_element("#button").click() + dash_duo.find_element("#tab-a:not(.tab--disabled)") + dash_duo.find_element("#add_button").click() + dash_duo.find_element("#test-1:not(.tab--disabled)")