diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e7b816d7..dc5b2e5d00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). +## Unreleased + +### 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 ### Added 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/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/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx index 8a017ed09d..2cb8b87f38 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,24 @@ 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) + // + // `_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 ( + _passedComponent?._dashprivate_remount || + (renderedIdentity.current !== null && + renderedIdentity.current !== identity) + ) { + freshRenders.current += 1; + } + renderedIdentity.current = identity; if (renderH in memoizedKeys.current) { delete memoizedKeys.current[renderH]; } 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/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_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" diff --git a/tests/integration/renderer/test_redraw.py b/tests/integration/renderer/test_redraw.py index 99d1141985..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 @@ -27,13 +27,145 @@ 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 + + +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"})