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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1needed for current cyclebugsomething brokensize: 3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions