From cdd72145dcd1dbd6cff42a4de6310410e14bff2c Mon Sep 17 00:00:00 2001 From: philippe Date: Tue, 7 Jul 2026 10:01:31 -0400 Subject: [PATCH 1/4] Only remount parent-passed components when their identity changed Since #3570, every component passed down from a parent whose children were updated by a callback got a bumped render key, forcing React to unmount and remount the entire subtree on every callback run - even when the returned children were the same components with only new prop values. This reset descendant state and made large subtree updates 3-4x slower (#3846). The forced remount is now conditional: the render key is only bumped when the component identity (namespace, type, id) at that path actually changed. The same component reconciles in place as before 4.2.0, while a different component at the same path still remounts, and stale descendant layout hashes are still reset - keeping #3330 fixed. The one reverted semantic from #3570 is that returning the same component (same type and id) from a callback no longer resets its internal state; return it with a different id to force a remount. Fixes #3846 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 + .../dash-renderer/src/wrapper/DashWrapper.tsx | 25 ++++- tests/integration/renderer/test_redraw.py | 95 ++++++++++++++++++- 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e7b816d7..68f2915c4a 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 +- [#3846](https://github.com/plotly/dash/issues/3846) Fix children returned by a callback being unmounted and remounted on every update instead of reconciled in place, which reset component state and slowed rendering of large subtrees 3-4x (regression introduced in 4.2.0 by [#3570](https://github.com/plotly/dash/pull/3570)). A component passed from a parent is now only remounted when its identity (namespace, type or id) at that position actually changed; the same component with new prop values updates in place, restoring pre-4.2 behavior. To force a remount of a stateful component from a callback, return it with a different `id`. + ## [4.4.0] - 2026-07-03 ### Added diff --git a/dash/dash-renderer/src/wrapper/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx index 8a017ed09d..c1cc18038b 100644 --- a/dash/dash-renderer/src/wrapper/DashWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/DashWrapper.tsx @@ -66,6 +66,16 @@ type MemoizedKeysType = { [key: string]: React.ReactNode | null; // This includes React elements, strings, numbers, etc. }; +// Identity of a dash component: which component it is, not what its +// current prop values are. Used to decide between remounting (identity +// changed) and reconciling in place (same component, new props). +const componentIdentity = (component: any) => { + const id = component?.props?.id; + return `${component?.namespace}.${component?.type}.${ + id ? stringifyId(id) : '' + }`; +}; + function DashWrapper({ componentPath, _dashprivate_error, @@ -77,6 +87,7 @@ function DashWrapper({ const memoizedKeys: MutableRefObject = useRef({}); const newRender = useRef(false); const freshRenders = useRef(0); + const renderedIdentity: MutableRefObject = useRef(null); const renderedPath = useRef(componentPath); let renderComponent: any = null; let renderComponentProps: any = null; @@ -97,7 +108,19 @@ function DashWrapper({ if (_newRender) { newRender.current = true; renderH = 0; - freshRenders.current += 1; + // Only force a remount (via the `key` bump below) when the + // component identity at this path actually changed. When the + // same component is passed again (eg: a callback returning + // updated children with the same structure), reconcile in + // place instead of unmounting the whole subtree. (#3846) + const identity = componentIdentity(_passedComponent); + if ( + renderedIdentity.current !== null && + renderedIdentity.current !== identity + ) { + freshRenders.current += 1; + } + renderedIdentity.current = identity; if (renderH in memoizedKeys.current) { delete memoizedKeys.current[renderH]; } diff --git a/tests/integration/renderer/test_redraw.py b/tests/integration/renderer/test_redraw.py index 99d1141985..099da1148f 100644 --- a/tests/integration/renderer/test_redraw.py +++ b/tests/integration/renderer/test_redraw.py @@ -27,13 +27,98 @@ def on_click(_): dash_duo.start_server(app) + # The same component (same type & id) returned from a callback is + # reconciled in place: it re-renders (counter increments) instead of + # being unmounted & remounted (which would reset the counter to 1). dash_duo.wait_for_text_to_equal("#counter", "1") dash_duo.find_element("#redraw").click() - # dash_duo.wait_for_text_to_equal("#counter", "2") - # time.sleep(1) - # dash_duo.wait_for_text_to_equal("#counter", "2") + dash_duo.wait_for_text_to_equal("#counter", "2") + time.sleep(1) + dash_duo.wait_for_text_to_equal("#counter", "2") + + +def test_rdraw002_remount_on_identity_change(dash_duo): + # A *different* component (type change) at the same path must be + # remounted, not reconciled: the counter resets on each swap back. + app = Dash() + + app.layout = html.Div( + [ + html.Div( + dt.DrawCounter(id="counter"), + id="redrawer", + ), + html.Button("redraw", id="redraw"), + ] + ) + + @app.callback( + Output("redrawer", "children"), + Input("redraw", "n_clicks"), + prevent_initial_call=True, + ) + def on_click(n): + if n % 2: + return html.Div("not a counter", id="counter") + return dt.DrawCounter(id="counter") + + dash_duo.start_server(app) - ## the above was changed due to a mechanism change that generates a new React component, thus resetting the counter dash_duo.wait_for_text_to_equal("#counter", "1") - time.sleep(1) + dash_duo.find_element("#redraw").click() + dash_duo.wait_for_text_to_equal("#counter", "not a counter") + dash_duo.find_element("#redraw").click() + # Fresh mount, not a third render of the original counter. dash_duo.wait_for_text_to_equal("#counter", "1") + + +def test_rdraw003_children_update_in_place(dash_duo): + # Children returned from a callback with an unchanged structure must + # update the existing DOM nodes in place, not unmount/remount the + # whole subtree. Regression test for #3846. + app = Dash() + n_rows = 20 + + app.layout = html.Div( + [ + html.Button("Re-render", id="btn", n_clicks=0), + html.Div(id="content"), + ] + ) + + @app.callback(Output("content", "children"), Input("btn", "n_clicks")) + def render(n): + return [ + html.Div( + [html.Span(f"Field {i}"), html.Span(f"value {i} @ click {n}")], + className="row", + ) + for i in range(n_rows) + ] + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal( + "#content .row:first-child span:last-child", "value 0 @ click 0" + ) + + # Tag every rendered element; remounted elements lose the tag. + dash_duo.driver.execute_script( + "document.querySelectorAll('#content *')" + ".forEach(el => el.__dash_test_probe = true)" + ) + n_elements = dash_duo.driver.execute_script( + "return document.querySelectorAll('#content *').length" + ) + assert n_elements == n_rows * 3 + + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal( + "#content .row:first-child span:last-child", "value 0 @ click 1" + ) + + reused = dash_duo.driver.execute_script( + "return [...document.querySelectorAll('#content *')]" + ".filter(el => el.__dash_test_probe).length" + ) + assert reused == n_elements From fa9383425a61384136ab3d3506dc9174f50a5dde Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 15 Jul 2026 11:43:35 -0400 Subject: [PATCH 2/4] fix component as props on updates --- CHANGELOG.md | 1 + dash/dash-renderer/src/actions/index.js | 7 + .../src/wrapper/ExternalWrapper.tsx | 42 ++++-- .../renderer/test_component_as_prop_repro.py | 124 ++++++++++++++++++ 4 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 tests/integration/renderer/test_component_as_prop_repro.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 68f2915c4a..069d906764 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Fixed - [#3846](https://github.com/plotly/dash/issues/3846) Fix children returned by a callback being unmounted and remounted on every update instead of reconciled in place, which reset component state and slowed rendering of large subtrees 3-4x (regression introduced in 4.2.0 by [#3570](https://github.com/plotly/dash/pull/3570)). A component passed from a parent is now only remounted when its identity (namespace, type or id) at that position actually changed; the same component with new prop values updates in place, restoring pre-4.2 behavior. To force a remount of a stateful component from a callback, return it with a different `id`. +- Fix components rendered as props (eg. component labels in `dcc.Dropdown` options, `dcc.Tab` labels) crashing with "can't access property 'props', layout is undefined" or failing to update when the host component's subtree was replaced by a callback. Components inserted out of the layout tree via `ExternalWrapper` now re-insert themselves when their layout entry was removed, so they update in place instead of updating a stale path. ## [4.4.0] - 2026-07-03 diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js index e595f79f9a..f54c0ed2af 100644 --- a/dash/dash-renderer/src/actions/index.js +++ b/dash/dash-renderer/src/actions/index.js @@ -34,6 +34,13 @@ export const resetComponentState = createAction( export function updateProps(payload) { return (dispatch, getState) => { const component = path(payload.itempath, getState().layout); + // The component may no longer exist at this path - eg. an + // `ExternalWrapper` (components as props) whose host subtree was + // replaced by a callback. Updating props of a component that isn't + // in the layout is a no-op and would crash `recordUiEdit`. + if (!component) { + return; + } recordUiEdit(component, payload.props, dispatch); dispatch(onPropChange(payload)); }; diff --git a/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx index 78807b2145..6d3c0c74de 100644 --- a/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/ExternalWrapper.tsx @@ -1,4 +1,5 @@ import React, {useState, useEffect} from 'react'; +import {path} from 'ramda'; import {batch, useDispatch} from 'react-redux'; import {DashComponent, DashLayoutPath} from '../types/component'; @@ -41,18 +42,35 @@ function ExternalWrapper({component, componentPath, temp = false}: Props) { }, []); useEffect(() => { - batch(() => { - dispatch( - updateProps({itempath: componentPath, props: component.props}) - ); - if (component.props.id) { - dispatch( - notifyObservers({ - id: component.props.id, - props: component.props - }) - ); - } + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + dispatch((_dispatch: any, getState: any) => { + // The host subtree may have been replaced (eg. a callback + // returned new children) while this wrapper reconciled in place + // rather than remounting. In that case the component is gone from + // the layout at `componentPath`, so re-insert it instead of + // updating a path that no longer exists. + const exists = path(componentPath, getState().layout); + batch(() => { + if (exists) { + _dispatch( + updateProps({ + itempath: componentPath, + props: component.props + }) + ); + } else { + _dispatch(addComponentToLayout({component, componentPath})); + } + if (component.props.id) { + _dispatch( + notifyObservers({ + id: component.props.id, + props: component.props + }) + ); + } + }); }); }, [component.props]); diff --git a/tests/integration/renderer/test_component_as_prop_repro.py b/tests/integration/renderer/test_component_as_prop_repro.py new file mode 100644 index 0000000000..a96724ff56 --- /dev/null +++ b/tests/integration/renderer/test_component_as_prop_repro.py @@ -0,0 +1,124 @@ +from dash import html, Dash, Output, Input, callback, dcc, ALL, State + + +def create_dropdown_option(name): + return { + "value": f"value_{name}", + "label": dcc.Markdown(f"label_{name}"), + "title": f"title_{name}", + } + + +def get_dropdown_options(num_of_options): + return [create_dropdown_option(name) for name in range(num_of_options)] + + +def test_capr001_dynamic_dropdown_component_labels(dash_duo): + # Regression test for the community bug where dynamically regenerating + # dropdowns whose option labels are components (components as props), + # with persistence on, while appending a new option, crashed with + # "can't access property 'props', layout is undefined". + # + # The label components are rendered out-of-tree via `ExternalWrapper`. + # When the hosting subtree is replaced by a callback while the wrapper + # reconciles in place (rather than remounting), its layout entry was + # wiped but it still tried to update props at the stale path. The + # wrapper now re-inserts itself, and `updateProps` no-ops on a missing + # path instead of crashing. + app = Dash(__name__) + + app.layout = html.Div( + id="top-level-component", + children=[ + dcc.Dropdown( + ["option_set_1", "option_set_2"], + "option_set_1", + id="dropdown_selector", + persistence=True, + persistence_type="local", + ), + html.Div(id="dropdowns_container", children=[]), + ], + ) + + @callback( + Output("dropdowns_container", "children"), + Input("dropdown_selector", "value"), + State({"aio_id": "extensible_dropdown", "index": ALL}, "options"), + ) + def swap_dropdowns(dropdown_selector_value, prev_options): + number_of_dds = 2 if dropdown_selector_value == "option_set_1" else 4 + if prev_options: + options = prev_options[0] + else: + options = get_dropdown_options(3) + + options.append(create_dropdown_option(len(options))) + + dropdowns_to_output = [] + for i in range(number_of_dds): + dropdowns_to_output.append( + html.Div( + [ + html.Div(f"Input: {i}"), + dcc.Dropdown( + options=options, + id={"aio_id": "extensible_dropdown", "index": f"{i}"}, + persistence=True, + persistence_type="local", + ), + ] + ) + ) + return dropdowns_to_output + + dash_duo.start_server(app) + + # Two extensible dropdowns render on load. + dash_duo._wait_for( + lambda _: len(dash_duo.find_elements("#dropdowns_container .dash-dropdown")) + == 2, + timeout=5, + msg="expected 2 extensible dropdowns on load", + ) + + # Open the first extensible dropdown and select its first option: this + # mounts the option's component label into the value display. + dash_duo.find_elements("#dropdowns_container .dash-dropdown")[0].click() + dash_duo.wait_for_element(".dash-dropdown-option") + dash_duo.find_elements(".dash-dropdown-option")[0].click() + dash_duo.wait_for_text_to_equal( + "#dropdowns_container .dash-dropdown-value", "label_0" + ) + + # Swap to option_set_2: regenerates all dropdowns and appends a new + # option. This used to crash and leave the container unchanged. + dash_duo.find_element("#dropdown_selector").click() + dash_duo.wait_for_element("#dropdown_selector ~ * .dash-dropdown-option") + for opt in dash_duo.find_elements(".dash-dropdown-option"): + if "option_set_2" in opt.text: + opt.click() + break + + # Four extensible dropdowns now render... + dash_duo._wait_for( + lambda _: len(dash_duo.find_elements("#dropdowns_container .dash-dropdown")) + == 4, + timeout=5, + msg="expected 4 extensible dropdowns after swap", + ) + # ...the persisted selection's component label still displays... + dash_duo.wait_for_text_to_equal( + "#dropdowns_container .dash-dropdown-value", "label_0" + ) + + # ...and the appended option's component label renders when opened. + dash_duo.find_elements("#dropdowns_container .dash-dropdown")[0].click() + dash_duo._wait_for( + lambda _: [e.text for e in dash_duo.find_elements(".dash-dropdown-option")] + == ["label_0", "label_1", "label_2", "label_3", "label_4"], + timeout=5, + msg="appended component label should render after swap", + ) + + assert dash_duo.get_logs() == [], "browser console errors after swap" From a26941d126a27f0db21cd7065284bed9d4408878 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 15 Jul 2026 14:10:27 -0400 Subject: [PATCH 3/4] Add dash.remount to force-reset a component from a callback Keeps in-place reconcile as the default and adds an opt-in wrapper for callback returns that need a hard reset. remount() sets a private top-level marker (emitted by to_plotly_json, not a prop) that the renderer reads to bump the render key, unmounting the existing instance and mounting a fresh one instead of reconciling in place. This resets a stateful component's internal state without changing its id. --- CHANGELOG.md | 7 ++- dash/__init__.py | 2 + dash/_remount.py | 36 ++++++++++++++ .../dash-renderer/src/wrapper/DashWrapper.tsx | 9 +++- dash/development/base_component.py | 6 +++ tests/integration/renderer/test_redraw.py | 49 ++++++++++++++++++- tests/unit/test_remount.py | 29 +++++++++++ 7 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 dash/_remount.py create mode 100644 tests/unit/test_remount.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 069d906764..dc5b2e5d00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,11 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Unreleased -## Fixed -- [#3846](https://github.com/plotly/dash/issues/3846) Fix children returned by a callback being unmounted and remounted on every update instead of reconciled in place, which reset component state and slowed rendering of large subtrees 3-4x (regression introduced in 4.2.0 by [#3570](https://github.com/plotly/dash/pull/3570)). A component passed from a parent is now only remounted when its identity (namespace, type or id) at that position actually changed; the same component with new prop values updates in place, restoring pre-4.2 behavior. To force a remount of a stateful component from a callback, return it with a different `id`. +### Added +- Added `dash.remount`, a wrapper for a component returned from a callback that forces the renderer to remount it - unmounting the existing instance and mounting a fresh one, resetting its internal state - instead of reconciling it in place. This is the explicit, opt-in way to reset a stateful component (eg. AG Grid, a dropdown keeping transient UI state) from a callback without having to change its `id`. + +### Fixed +- [#3846](https://github.com/plotly/dash/issues/3846) Fix children returned by a callback being unmounted and remounted on every update instead of reconciled in place, which reset component state and slowed rendering of large subtrees 3-4x (regression introduced in 4.2.0 by [#3570](https://github.com/plotly/dash/pull/3570)). A component passed from a parent is now only remounted when its identity (namespace, type or id) at that position actually changed; the same component with new prop values updates in place, restoring pre-4.2 behavior. To force a remount of a stateful component from a callback, wrap it with `dash.remount` (or return it with a different `id`). - Fix components rendered as props (eg. component labels in `dcc.Dropdown` options, `dcc.Tab` labels) crashing with "can't access property 'props', layout is undefined" or failing to update when the host component's subtree was replaced by a callback. Components inserted out of the layout tree via `ExternalWrapper` now re-insert themselves when their layout entry was removed, so they update in place instead of updating a stale path. ## [4.4.0] - 2026-07-03 diff --git a/dash/__init__.py b/dash/__init__.py index 6f16a068aa..cd0c0831c4 100644 --- a/dash/__init__.py +++ b/dash/__init__.py @@ -41,6 +41,7 @@ page_container, ) from ._patch import Patch # noqa: F401,E402 +from ._remount import remount # noqa: F401,E402 from ._jupyter import jupyter_dash # noqa: F401,E402 from ._hooks import hooks # noqa: F401,E402 @@ -90,6 +91,7 @@ def _jupyter_nbextension_paths(): "NoUpdate", "page_container", "Patch", + "remount", "jupyter_dash", "ctx", "hooks", diff --git a/dash/_remount.py b/dash/_remount.py new file mode 100644 index 0000000000..ab536d7ca2 --- /dev/null +++ b/dash/_remount.py @@ -0,0 +1,36 @@ +from typing import Any + +from .development.base_component import Component + +# Private, non-prop marker set by `remount` and read by the renderer +# (DashWrapper). `Component.to_plotly_json` emits it as a top-level key, so +# it never enters the component's props nor triggers prop validation. +REMOUNT_ATTR = "_dashprivate_remount" + + +def remount(component: Any) -> Any: + """Force a component returned from a callback to be remounted. + + By default, when a callback returns a component that is the same (same + ``type`` and ``id``) as the one already rendered at that position, Dash + reconciles it in place: prop values update but the component instance is + kept, so any internal state it holds (for example an AG Grid's selection + or a component's transient UI state) is preserved. + + Wrapping the returned component with ``remount`` instead forces the + renderer to unmount the existing instance and mount a fresh one, + resetting its internal state to match what the callback returned. It is + the explicit, opt-in equivalent of returning the component with a + different ``id``, without having to change the ``id``. + + >>> from dash import Dash, Input, Output, callback, dcc, html, remount + >>> @callback(Output("box", "children"), Input("btn", "n_clicks")) + ... def cb(n): + ... return remount(dcc.Dropdown(id="d", options=options)) + """ + if not isinstance(component, Component): + raise TypeError( + "remount() expects a Dash component, got " f"{type(component).__name__}." + ) + setattr(component, REMOUNT_ATTR, True) + return component diff --git a/dash/dash-renderer/src/wrapper/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx index c1cc18038b..2cb8b87f38 100644 --- a/dash/dash-renderer/src/wrapper/DashWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/DashWrapper.tsx @@ -113,10 +113,15 @@ function DashWrapper({ // same component is passed again (eg: a callback returning // updated children with the same structure), reconcile in // place instead of unmounting the whole subtree. (#3846) + // + // `_dashprivate_remount` is set by `dash.remount()` and forces + // a remount even when the identity is unchanged, letting a + // callback explicitly reset a component's internal state. const identity = componentIdentity(_passedComponent); if ( - renderedIdentity.current !== null && - renderedIdentity.current !== identity + _passedComponent?._dashprivate_remount || + (renderedIdentity.current !== null && + renderedIdentity.current !== identity) ) { freshRenders.current += 1; } diff --git a/dash/development/base_component.py b/dash/development/base_component.py index d7a473c25e..b6798064e1 100644 --- a/dash/development/base_component.py +++ b/dash/development/base_component.py @@ -291,6 +291,12 @@ def to_plotly_json(self): "namespace": self._namespace, # pylint: disable=no-member } + # Set by `dash.remount()`. A top-level marker (not a prop) that tells + # the renderer to remount this component instead of reconciling it in + # place, resetting its internal state. + if getattr(self, "_dashprivate_remount", False): + as_json["_dashprivate_remount"] = True + return as_json # pylint: disable=too-many-branches, too-many-return-statements diff --git a/tests/integration/renderer/test_redraw.py b/tests/integration/renderer/test_redraw.py index 099da1148f..de79487e6d 100644 --- a/tests/integration/renderer/test_redraw.py +++ b/tests/integration/renderer/test_redraw.py @@ -1,5 +1,5 @@ import time -from dash import Dash, Input, Output, html +from dash import Dash, Input, Output, html, ctx, remount import dash_test_components as dt @@ -122,3 +122,50 @@ def render(n): ".filter(el => el.__dash_test_probe).length" ) assert reused == n_elements + + +def test_rdraw004_explicit_remount(dash_duo): + # `dash.remount()` forces a remount (resetting internal state) even when + # the component identity is unchanged, without having to change the id. + # The same component returned plain reconciles in place (counter keeps + # incrementing); wrapped in remount() it resets to 1. + app = Dash() + + app.layout = html.Div( + [ + html.Div(dt.DrawCounter(id="counter"), id="box"), + html.Button("plain", id="plain"), + html.Button("remount", id="remount"), + ] + ) + + @app.callback( + Output("box", "children"), + Input("plain", "n_clicks"), + Input("remount", "n_clicks"), + prevent_initial_call=True, + ) + def update(_plain, _remount): + if ctx.triggered_id == "remount": + return remount(dt.DrawCounter(id="counter")) + return dt.DrawCounter(id="counter") + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#counter", "1") + # Plain re-renders reconcile in place: the counter increments. + dash_duo.find_element("#plain").click() + dash_duo.wait_for_text_to_equal("#counter", "2") + dash_duo.find_element("#plain").click() + dash_duo.wait_for_text_to_equal("#counter", "3") + # remount() resets internal state. + dash_duo.find_element("#remount").click() + dash_duo.wait_for_text_to_equal("#counter", "1") + # ...and reconciliation resumes afterwards. + dash_duo.find_element("#plain").click() + dash_duo.wait_for_text_to_equal("#counter", "2") + # remount() works repeatedly. + dash_duo.find_element("#remount").click() + dash_duo.wait_for_text_to_equal("#counter", "1") + + assert dash_duo.get_logs() == [] diff --git a/tests/unit/test_remount.py b/tests/unit/test_remount.py new file mode 100644 index 0000000000..328179223e --- /dev/null +++ b/tests/unit/test_remount.py @@ -0,0 +1,29 @@ +import pytest + +from dash import remount, html + + +def test_remount_adds_top_level_marker_not_a_prop(): + component = remount(html.Div("hello", id="d")) + as_json = component.to_plotly_json() + + # Marker is a top-level key, a sibling of props/type/namespace... + assert as_json["_dashprivate_remount"] is True + # ...and never leaks into the component's props. + assert "_dashprivate_remount" not in as_json["props"] + + +def test_remount_returns_same_component(): + div = html.Div(id="d") + assert remount(div) is div + + +def test_plain_component_has_no_marker(): + assert "_dashprivate_remount" not in html.Div(id="d").to_plotly_json() + + +def test_remount_rejects_non_components(): + with pytest.raises(TypeError): + remount("not a component") + with pytest.raises(TypeError): + remount({"type": "Div"}) From 25d4bddd3d5f985eadbfe93bc31115430e52563e Mon Sep 17 00:00:00 2001 From: philippe Date: Mon, 10 Aug 2026 11:24:07 -0400 Subject: [PATCH 4/4] Keep initial state set on mount from being wiped on first render (#3929) A component's descendant layout hashes were reset on its very first fresh render. Components that establish their own initial state on mount via setProps - eg. dbc Tabs selecting its default active_tab - had that update discarded before it took effect, so the default tab's content did not show until the user interacted with it. Only reset descendant hashes from the second fresh render onward. Stale descendant hashes are only a concern once the subtree has rendered at least once, so #3330 stays fixed while mount-time state now survives. Adds MountStateComponent test component (setProps on mount) and integration tests covering the direct-child and nested cases. --- .../src/components/MountStateComponent.js | 22 ++++++++++++ @plotly/dash-test-components/src/index.js | 2 ++ CHANGELOG.md | 1 + .../dash-renderer/src/wrapper/DashWrapper.tsx | 21 +++++++++--- .../integration/renderer/test_mount_state.py | 34 +++++++++++++++++++ 5 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 @plotly/dash-test-components/src/components/MountStateComponent.js create mode 100644 tests/integration/renderer/test_mount_state.py diff --git a/@plotly/dash-test-components/src/components/MountStateComponent.js b/@plotly/dash-test-components/src/components/MountStateComponent.js new file mode 100644 index 0000000000..b1bc1a17b9 --- /dev/null +++ b/@plotly/dash-test-components/src/components/MountStateComponent.js @@ -0,0 +1,22 @@ +import React, {useEffect} from "react"; +import PropTypes from "prop-types"; + +// A component that establishes its own initial state on mount by calling +// setProps - the same pattern dbc Tabs uses to pick its default active_tab +// (#3929). Used to guard against the first-render resetComponentState wiping +// that mount-time update before it takes effect. +const MountStateComponent = ({id, setProps, value = "initial"}) => { + useEffect(() => { + setProps({value: "mounted"}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return
{value}
; +}; + +MountStateComponent.propTypes = { + id: PropTypes.string, + value: PropTypes.string, + setProps: PropTypes.func, +}; + +export default MountStateComponent; diff --git a/@plotly/dash-test-components/src/index.js b/@plotly/dash-test-components/src/index.js index 9a6523b22c..89163fdda3 100644 --- a/@plotly/dash-test-components/src/index.js +++ b/@plotly/dash-test-components/src/index.js @@ -15,6 +15,7 @@ import ReceivePropsComponent from "./components/ReceivePropsComponent"; import ShapeOrExactKeepOrderComponent from "./components/ShapeOrExactKeepOrderComponent"; import ArrayOfExactOrShapeWithNodePropAssignNone from './components/ArrayOfExactOrShapeWithNodePropAssignNone'; import ExternalComponent from './components/ExternalComponent'; +import MountStateComponent from './components/MountStateComponent'; export { @@ -33,5 +34,6 @@ export { ShapeOrExactKeepOrderComponent, ArrayOfExactOrShapeWithNodePropAssignNone, ExternalComponent, + MountStateComponent, RenderType }; diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5b2e5d00..3f58d3b73a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ### Fixed - [#3846](https://github.com/plotly/dash/issues/3846) Fix children returned by a callback being unmounted and remounted on every update instead of reconciled in place, which reset component state and slowed rendering of large subtrees 3-4x (regression introduced in 4.2.0 by [#3570](https://github.com/plotly/dash/pull/3570)). A component passed from a parent is now only remounted when its identity (namespace, type or id) at that position actually changed; the same component with new prop values updates in place, restoring pre-4.2 behavior. To force a remount of a stateful component from a callback, wrap it with `dash.remount` (or return it with a different `id`). - Fix components rendered as props (eg. component labels in `dcc.Dropdown` options, `dcc.Tab` labels) crashing with "can't access property 'props', layout is undefined" or failing to update when the host component's subtree was replaced by a callback. Components inserted out of the layout tree via `ExternalWrapper` now re-insert themselves when their layout entry was removed, so they update in place instead of updating a stale path. +- [#3929](https://github.com/plotly/dash/issues/3929) Fix components that set their own initial state on mount (eg. `dash-bootstrap-components` `Tabs`, which selects its default active tab) not applying that state on first render - a component's descendant layout hashes were reset on its very first fresh render, discarding the mount-time update before it took effect. The reset now only runs from the second fresh render onward, so a component's initial state survives (regression introduced in 4.2.0 by [#3570](https://github.com/plotly/dash/pull/3570)). ## [4.4.0] - 2026-07-03 diff --git a/dash/dash-renderer/src/wrapper/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx index 2cb8b87f38..d610b7f263 100644 --- a/dash/dash-renderer/src/wrapper/DashWrapper.tsx +++ b/dash/dash-renderer/src/wrapper/DashWrapper.tsx @@ -88,6 +88,7 @@ function DashWrapper({ const newRender = useRef(false); const freshRenders = useRef(0); const renderedIdentity: MutableRefObject = useRef(null); + const hasFreshRendered = useRef(false); const renderedPath = useRef(componentPath); let renderComponent: any = null; let renderComponentProps: any = null; @@ -475,11 +476,21 @@ function DashWrapper({ useEffect(() => { if (_newRender) { - dispatch( - resetComponentState({ - itempath: componentPath - }) - ); + // Don't reset descendant layout hashes on the component's very + // first fresh render: components that set their initial state on + // mount (eg. dbc Tabs picking the default active tab) would have + // that state wiped before it takes effect. Stale descendant + // hashes are only a concern once the subtree has rendered at + // least once, so reset from the second fresh render onward. + // (#3929, keeps #3330 fixed.) + if (hasFreshRendered.current) { + dispatch( + resetComponentState({ + itempath: componentPath + }) + ); + } + hasFreshRendered.current = true; } }, [_newRender]); diff --git a/tests/integration/renderer/test_mount_state.py b/tests/integration/renderer/test_mount_state.py new file mode 100644 index 0000000000..4d7147960c --- /dev/null +++ b/tests/integration/renderer/test_mount_state.py @@ -0,0 +1,34 @@ +from dash import Dash, html + +import dash_test_components as dt + + +def test_mnst001_mount_setprops_survives_first_render(dash_duo): + # Regression test for #3929: a component that establishes its own initial + # state on mount via setProps (the pattern dbc Tabs uses to select its + # default active_tab) had that update wiped on the very first render, + # because the parent's fresh render reset all descendant layout hashes + # before the mount-time update took effect. The component is rendered as + # a child (a children-prop, which is flagged as a fresh render on first + # mount), so it exercises that reset path. + app = Dash(__name__) + app.layout = html.Div(dt.MountStateComponent(id="m")) + + dash_duo.start_server(app) + + # "mounted" is set by the component's mount effect; "initial" is the + # default that remains if the update was wiped. + dash_duo.wait_for_text_to_equal("#m", "mounted", timeout=6) + assert dash_duo.get_logs() == [] + + +def test_mnst002_mount_setprops_survives_when_nested(dash_duo): + # Same as above but nested deeper, so the reset originates from an + # ancestor rather than the direct parent. + app = Dash(__name__) + app.layout = html.Div(html.Div(dt.MountStateComponent(id="m"))) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#m", "mounted", timeout=6) + assert dash_duo.get_logs() == []