diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e7b816d7..68f2915c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dash/dash-renderer/src/wrapper/DashWrapper.tsx b/dash/dash-renderer/src/wrapper/DashWrapper.tsx index 8a017ed09d..c1cc18038b 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,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]; } diff --git a/tests/integration/renderer/test_redraw.py b/tests/integration/renderer/test_redraw.py index 99d1141985..099da1148f 100644 --- a/tests/integration/renderer/test_redraw.py +++ b/tests/integration/renderer/test_redraw.py @@ -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