Skip to content

[BUG] 4.2.0: callback-rendered children unmount/remount instead of updating in place (regression, bisects to #3570) #3846

Description

@romanov-o

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions