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
Original file line number Diff line number Diff line change
@@ -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 <div id={id}>{value}</div>;
};

MountStateComponent.propTypes = {
id: PropTypes.string,
value: PropTypes.string,
setProps: PropTypes.func,
};

export default MountStateComponent;
2 changes: 2 additions & 0 deletions @plotly/dash-test-components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,5 +34,6 @@ export {
ShapeOrExactKeepOrderComponent,
ArrayOfExactOrShapeWithNodePropAssignNone,
ExternalComponent,
MountStateComponent,
RenderType
};
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
All notable changes to `dash` will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]
## 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`.
- [#3646](https://github.com/plotly/dash/pull/3646) Experimental support for React 19. The default is still React 18.3.1; to use React 19 set the environment variable `REACT_VERSION=19.2.4` before running your app, or call `dash._dash_renderer._set_react_version("19.2.4")` inside the app. React 19 has no official UMD builds, so Dash serves the [`umd-react`](https://www.npmjs.com/package/umd-react) package, together with a compatibility shim loaded after react-dom and before any component package. The shim keeps component libraries built against React <=18 (e.g. dash-bootstrap-components, dash-mantine-components) working under React 19: it stubs the removed `ReactCurrentOwner` internals, redirects the legacy element `$$typeof` symbol so pre-bundled React 18 jsx-runtimes produce elements React 19 accepts (error #525), and exposes a global `react/jsx-runtime` (`window.ReactJSXRuntime`) that Dash's own component bundles externalize to. Component library authors adopting this convention should copy the defensive `jsxRuntimeExternal` webpack external from `components/dash-core-components/webpack.config.js` rather than a bare `'ReactJSXRuntime'` string: it falls back to a `React.createElement`-based runtime when the global is missing, so the same build also works on Dash versions older than this release.

### Removed
Expand All @@ -15,6 +16,9 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- [#3916](https://github.com/plotly/dash/pull/3916) Fixed a regression where dragging multiple files into `dcc.Upload` would upload only the first file when `multiple=True`
- [#3922](https://github.com/plotly/dash/pull/3922) Fix `dcc.Input(type="number")` stepper behavior when only `min` is set.
- [#3925](https://github.com/plotly/dash/pull/3925) Use the proxied url as the Jupyter server url so `DASH_PROXY` is honored by the external url and inline iframe in notebooks.
- [#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.1] - 2026-07-21

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
51 changes: 45 additions & 6 deletions 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,8 @@ function DashWrapper({
const memoizedKeys: MutableRefObject<MemoizedKeysType> = useRef({});
const newRender = useRef(false);
const freshRenders = useRef(0);
const renderedIdentity: MutableRefObject<string | null> = useRef(null);
const hasFreshRendered = useRef(false);
const renderedPath = useRef<DashLayoutPath>(componentPath);
let renderComponent: any = null;
let renderComponentProps: any = null;
Expand All @@ -97,7 +109,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 Expand Up @@ -447,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]);

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
Loading