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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 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,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];
}
Expand Down
95 changes: 90 additions & 5 deletions tests/integration/renderer/test_redraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading