Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions dash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,6 +91,7 @@ def _jupyter_nbextension_paths():
"NoUpdate",
"page_container",
"Patch",
"remount",
"jupyter_dash",
"ctx",
"hooks",
Expand Down
36 changes: 36 additions & 0 deletions dash/_remount.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions dash/dash-renderer/src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
};
Expand Down
30 changes: 29 additions & 1 deletion dash/dash-renderer/src/wrapper/DashWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -77,6 +87,7 @@ function DashWrapper({
const memoizedKeys: MutableRefObject<MemoizedKeysType> = useRef({});
const newRender = useRef(false);
const freshRenders = useRef(0);
const renderedIdentity: MutableRefObject<string | null> = useRef(null);
const renderedPath = useRef<DashLayoutPath>(componentPath);
let renderComponent: any = null;
let renderComponentProps: any = null;
Expand All @@ -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];
}
Expand Down
42 changes: 30 additions & 12 deletions dash/dash-renderer/src/wrapper/ExternalWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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]);

Expand Down
6 changes: 6 additions & 0 deletions dash/development/base_component.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions tests/integration/renderer/test_component_as_prop_repro.py
Original file line number Diff line number Diff line change
@@ -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"
Loading