Summary
Since 4.2.0, when a callback returns a component subtree as the children of a parent, the entire subtree is unmounted and remounted on every callback run instead of being reconciled in place (as in 4.1.0). For large subtrees this is a ~3–4× client-side slowdown and also resets the subtree's component state. It bisects to #3570 ("Fix components not remounting when passed from a parent object"), which resets all descendant hashes. Not fixed in 4.3.0 (no renderer changes since 4.2.0).
Environment
- 4.2.0 and 4.3.0 affected; 4.1.0 is fine. Python 3.14.5, macOS arm64.
- Reproduces in both
debug=True (dev bundle) and debug=False (production bundle).
- No third-party component libraries needed — plain
dash.html.
Minimal reproducer
from dash import Dash, html, callback, Input, Output
app = Dash(__name__)
N = 200
app.layout = html.Div([
html.Button("Re-render", id="btn", n_clicks=0),
html.Div(id="content"),
])
@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)
]
if __name__ == "__main__":
app.run(debug=False, port=8064)
How to observe (browser console — pastes once, clicks once, reports)
(async () => {
const content = document.querySelector('#content');
[...content.querySelectorAll('*')].forEach((el, i) => el.__probe = i);
let added = 0, removed = 0, text = 0;
const obs = new MutationObserver(ms => ms.forEach(m => {
if (m.type === 'childList') { added += m.addedNodes.length; removed += m.removedNodes.length; }
else if (m.type === 'characterData') text++;
}));
obs.observe(content, { childList: true, subtree: true, characterData: true });
document.querySelector('#btn').click();
await new Promise(r => setTimeout(r, 500)); obs.disconnect();
const reused = [...content.querySelectorAll('*')].filter(e => e.__probe !== undefined).length;
console.log({ reusedExistingEls: reused, preCount: 600, nodesAdded: added, nodesRemoved: removed, textPatches: text });
})();
Measured behavior (200-row subtree = 600 elements)
| metric per re-render |
4.1.0 |
4.2.0 / 4.3.0 |
| existing elements reused |
600/600 (100%) |
0/600 (0%) |
in-place text patches (characterData) |
200 |
0 |
| DOM nodes added / removed |
0 / 0 |
200 / 200 |
| render time (this MRE) |
~66 ms |
~187 ms |
In a real app with a deeper subtree (a details panel of nested accordions, ~190–520 elements, rebuilt on row-select) the same remount makes the interaction ~4× slower (≈170 ms → ≈700–850 ms, production build) and resets component state every update.
Expected
New children from a callback should reconcile the existing subtree in place when the structure matches (4.1.0 behavior) — not unmount + remount every descendant.
Actual
Every descendant is unmounted and recreated on each callback run (0% DOM-node reuse, no in-place text patching).
Notes
#3570 ("resets all descendant hashes") appears to over-apply: it remounts parent-passed content even when only prop values change and the structure is unchanged.
Summary
Since 4.2.0, when a callback returns a component subtree as the
childrenof a parent, the entire subtree is unmounted and remounted on every callback run instead of being reconciled in place (as in 4.1.0). For large subtrees this is a ~3–4× client-side slowdown and also resets the subtree's component state. It bisects to #3570 ("Fix components not remounting when passed from a parent object"), which resets all descendant hashes. Not fixed in 4.3.0 (no renderer changes since 4.2.0).Environment
debug=True(dev bundle) anddebug=False(production bundle).dash.html.Minimal reproducer
How to observe (browser console — pastes once, clicks once, reports)
Measured behavior (200-row subtree = 600 elements)
characterData)In a real app with a deeper subtree (a details panel of nested accordions, ~190–520 elements, rebuilt on row-select) the same remount makes the interaction ~4× slower (≈170 ms → ≈700–850 ms, production build) and resets component state every update.
Expected
New
childrenfrom a callback should reconcile the existing subtree in place when the structure matches (4.1.0 behavior) — not unmount + remount every descendant.Actual
Every descendant is unmounted and recreated on each callback run (0% DOM-node reuse, no in-place text patching).
Notes
#3570 ("resets all descendant hashes") appears to over-apply: it remounts parent-passed content even when only prop values change and the structure is unchanged.