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 7ec0a56a44..e9f5eaddd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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
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..d610b7f263 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,8 @@ function DashWrapper({
const memoizedKeys: MutableRefObject = useRef({});
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;
@@ -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];
}
@@ -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]);
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_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() == []
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"})