Skip to content

Only remount parent-passed components when their identity changed#3881

Open
T4rk1n wants to merge 3 commits into
devfrom
fix/remounts
Open

Only remount parent-passed components when their identity changed#3881
T4rk1n wants to merge 3 commits into
devfrom
fix/remounts

Conversation

@T4rk1n

@T4rk1n T4rk1n commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Since #3570, every component passed down from a parent whose children were updated by a callback got a bumped render key, forcing React to unmount and remount the entire subtree on every callback run - even when the returned children were the same components with only new prop values. This reset descendant state and made large subtree updates 3-4x slower (#3846).

The forced remount is now conditional: the render key is only bumped when the component identity (namespace, type, id) at that path actually changed. The same component reconciles in place as before 4.2.0, while a different component at the same path still remounts, and stale descendant layout hashes are still reset - keeping #3330 fixed.

The one reverted semantic from #3570 is that returning the same component (same type and id) from a callback no longer resets its internal state; return it with a different id to force a remount.

Fixes #3846

Since #3570, every component passed down from a parent whose children
were updated by a callback got a bumped render key, forcing React to
unmount and remount the entire subtree on every callback run - even
when the returned children were the same components with only new prop
values. This reset descendant state and made large subtree updates 3-4x
slower (#3846).

The forced remount is now conditional: the render key is only bumped
when the component identity (namespace, type, id) at that path actually
changed. The same component reconciles in place as before 4.2.0, while
a different component at the same path still remounts, and stale
descendant layout hashes are still reset - keeping #3330 fixed.

The one reverted semantic from #3570 is that returning the same
component (same type and id) from a callback no longer resets its
internal state; return it with a different id to force a remount.

Fixes #3846

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@BSd3v

BSd3v commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

@T4rk1n

I believe that this resets to the initial behaviour of 4.1, this may be better served with a dash update type? For example, if the dev wants to reset the component via a callback, they just tell the callback to do so, which this forces its way down the tree? The default could be to not force remount.

@AnnMarieW

Copy link
Copy Markdown
Collaborator

I took this for a spin and ran the example apps that PR 3570 fixed, and they both worked correctly.
I also ran the example given in issue 3846 and verified that the performance has been restored to the same as in v4.1.

This looks good to me! @BSd3v are there any other use-cases to test?

@BSd3v

BSd3v commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

It's really just reimplementing a previous workaround and expanding the workaround features. This expands the use of arbitrary ids and can muddy up the DOM tree when unnecessary.

The main thing is that the dev wants to be able to reset the children-type to an initial state, while this workaround obviously works (as it had prior in 4.1) I feel that a more robust definitive property makes more sense. Instead of placing in the documents that in order to remount you need to change the id that contains the information or change what the component is, rather than the ability to pass something to the Output of a callback and force it.

@AnnMarieW

Copy link
Copy Markdown
Collaborator

Here's an example reported on the forum, that worked with pr 3570, but does not work on this branch:
https://community.plotly.com/t/dropdowns-and-dynamic-options-in-dash-4-0-0/96416

@T4rk1n

T4rk1n commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Here's an example reported on the forum, that worked with pr 3570, but does not work on this branch: https://community.plotly.com/t/dropdowns-and-dynamic-options-in-dash-4-0-0/96416

I implemented a fix for the component as props rendering, which seems to be the root cause of issues.

@BSd3v

BSd3v commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

I think the issue I have with this is that this just merges the component updates from the callback to the underlying components rather than resetting the children to their initially rendered state. There are several components that do this, AG Grid, dmc.Select to name a few. These components keep states within themselves that listen to updates.

I believe that a more robust and dev controlled way to handle would be keep the mechanism created here to handle normal cases, but allow for the dev to specifically reset a component to its initial state based upon kwargs passed to the output.

From an intuition perspective, it makes more sense for the component to reset from a callback the information that I am providing in a callback as the truth of what should be rendered, unless specifically patched.

Keeps in-place reconcile as the default and adds an opt-in wrapper for
callback returns that need a hard reset. remount() sets a private
top-level marker (emitted by to_plotly_json, not a prop) that the
renderer reads to bump the render key, unmounting the existing instance
and mounting a fresh one instead of reconciling in place. This resets a
stateful component's internal state without changing its id.
@T4rk1n

T4rk1n commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Resetting all the components all the time ends up being too expensive for the most simple cases as reported in #3846.

I've added dash.remount as an explicit way to reset the component entirely from a callback. Just wrap it around a component:

from dash import remount

@callback(Output("box", "children"), Input("btn", "n_clicks"))
def cb(n):
    return remount(dmc.Select(id="s", value=v))   # forces a fresh mount → resets internal state

@sonarqubecloud

Copy link
Copy Markdown

@BSd3v

BSd3v commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

To force a remount on a component in the clientside it would be:
{namespace, type, props, _dashprivate_remount} ?

@T4rk1n

T4rk1n commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

To force a remount on a component in the clientside it would be: {namespace, type, props, _dashprivate_remount} ?

Yes I confirm that works.

@BSd3v

BSd3v commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

To force a remount on a component in the clientside it would be: {namespace, type, props, _dashprivate_remount} ?

Yes I confirm that works.

I'm good with this.

@pip-install-python

pip-install-python commented Jul 25, 2026

Copy link
Copy Markdown

We hit this too, on a production Dash Enterprise app, and it's currently blocking us from moving off 4.1 — so a strong +1 on this landing. Sharing our numbers because I think #3846's MRE understates the real-world severity, which may be worth knowing when weighing this against the dash.remount() ergonomics discussion above.

Confirming the bisect at the wheel level. I pulled dash_renderer.dev.js out of each published wheel and diffed the DashWrapper module. The ComponentErrorBoundary element gets key: freshRenders.current in 4.2.0 and still has it in 4.4.1:

3.2.0 4.0.0 4.1.0 4.2.0 4.4.1
no key no key no key key key

Aside from that, the 3.2 → 4.2 renderer diff is almost entirely a Babel target change (ES5 → ES2015); react-redux is byte-identical and selectors.ts / wrapping.ts / StoreObserver.ts are behaviorally unchanged. The key really is the only behavioral delta in the render path.

Why we measured ~10× and not 3–4×. The remount cost scales with the mount expense of the returned subtree, not with its node count. An update on a mounted component is nearly free; a mount runs every effect. So components that do layout measurement or observer setup on mount are hit far harder than the html.Div/html.Span rows in the MRE.

Our worst case is dmc.Tooltip, which is Floating-UI under the hood — on mount it does a getBoundingClientRect (a forced synchronous layout), registers autoUpdate listeners, and sets up a portal. We had ~5 of them per panel × ~50 panels, so a single callback was doing 250–480 Floating-UI mounts, each forcing a reflow against a ~20k-node DOM.

A/B on one page (a callback opening an accordion for a large site, 96 equipment bases → ~50 panels; Dash 4.2.0, DMC 2.8. Measured with PerformanceObserver longtask in headless Chrome on a shared box, so treat the absolute numbers as inflated — the ratios are the point):

variant main-thread block
server-side callback (Python) 87 ms
bare AccordionItem, text only, same pattern-matched id 410 ms
full panel 11,940 ms
same panel, dmc.Tooltip removed 3,041 ms
also removing dmc.Avatar 2,977 ms

So ~75% of an ~11.9 s block was tooltips alone. Rewriting them to a native title attribute got us to ~3.3 s, which is what we're shipping as a stopgap — but it's a UI downgrade we'd like to revert once this PR lands. Generalizing: any Floating-UI-based component (Mantine Tooltip/Popover/Menu/HoverCard/Select), and anything registering a ResizeObserver or portal on mount, is a multiplier here. That's likely why reports of this range from "a bit slower" to "unusable."

One diagnostic note that might save others time. The Chrome trace for this is very misleading. It shows a single continuous ~10.7 s main-thread task with ~10.18 s attributed to dash_renderer.<version>.min.js and notifyNestedSubs / notify frames throughout, plus ~328 minor GCs (vs ~4 on 3.2). That reads like a Redux or callback-graph problem — we initially chased pattern-matching callbacks, and so did our support ticket. It isn't: react-redux is bundled inside the renderer while React ships as separate files, so a remount cascade re-subscribing every wrapper lands entirely under the renderer's attribution. The giveaway is that our bare-AccordionItem variant kept the identical pattern-matched id and was fast — the cost tracks content mount expense, not callback participation.

On the API discussion: the identity-based default plus explicit dash.remount() matches how we'd want it. Every one of our affected callbacks returns the same component types with new prop values, so in-place reconciliation is the correct default for us, and the handful of places we do want a state reset are ones we know about explicitly. Confirming stringifyId in componentIdentity handles our case — we use dict ids with MATCH heavily (~330 sites), and canonical stringification means those compare stably rather than by object reference.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants