Skip to content

Fix O(total) Patch().append() slowdown into a growing container - #3948

Open
T4rk1n wants to merge 2 commits into
fix/remountsfrom
perf/patch-append-rehydration
Open

Fix O(total) Patch().append() slowdown into a growing container#3948
T4rk1n wants to merge 2 commits into
fix/remountsfrom
perf/patch-append-rehydration

Conversation

@T4rk1n

@T4rk1n T4rk1n commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

dcc.Patch().append() (and .extend()) into a container that keeps growing gets progressively slower as the container fills — a fixed-size append starts costing seconds. This is a regression from 4.2.0, distinct from #3846/#3881 (remount) and #3938 (Patch re-firing callbacks / wiping persistence); none of those fix it.

Root cause: on every patch that updates children, two places did O(total-children) work regardless of how much the patch actually changed:

  • DashWrapper re-hydrated the entire children array — re-running Registry.resolve, prop hydration and recursive child-wrapping for every pre-existing child — even though a ramda [...old, new] append leaves the first N children reference-identical and unchanged. This dominated (>99% of the wall-clock ramp).
  • computePaths rebuilt the whole id→path table by re-crawling every existing child (asymptotically O(total), though small in absolute terms).

The fix

  • DashWrapper.tsx — a child that is the same object reference as the previous render skips re-hydration and reuses its cached render. Gated to a settled component reconciling in place (Boolean(renderH) && !newRender.current) — never a first render or a forced remount (dash.remount() / identity change), where re-rendering everything below is the intended behavior. Because only reference-identical children are skipped, anything a real change touches (insert, prepend, reorder, replace, prop/persistence write) produces a new object at that index and still renders normally.
  • patchAnalysis.ts / patch.ts — track per-property "pure tail-append" eligibility (tailAppends); any Insert/Prepend/Delete/Reverse/nested write disqualifies it.
  • paths.jsappendPaths() updates the path table for only the appended tail slice, used when the patch is a provable pure tail-append.

Measured (append cost for one 250-node append vs. container size)

existing nodes before (dev) after 4.1.0 ref
0 95 ms 82 ms 66 ms
1000 767 ms 94 ms 67 ms
2000 2419 ms 85 ms 82 ms
2750 4491 ms 85 ms 103 ms

Flat, back in the 4.1.0 band — ~53× faster at 2750 nodes. Benchmarked headless (Playwright) on a source build; numbers reproduced on the exact branch build.

Tests

14 new renderer tests (patch.test.js, paths.test.js): tail-append gating (Append/Extend counted; Insert/Prepend/Delete/nested-write disqualify) and appendPathscomputePaths equivalence, including pattern-matching (dict) ids and nested children. Full renderer suite: 50/50 pass, including #3938's patch/persistence tests.

Stacking

Stacked on #3881 (fix/remounts, which already contains merged #3938). Base is set to fix/remounts; retarget to dev once #3881 merges.

Repros the scenario from the (closed-as-dup) #3946 report. Worth confirming against that reporter's app before release.

Appending to a Patch-grown container got progressively slower as it filled:
each append re-hydrated the entire children array (re-running Registry.resolve
and prop hydration for every pre-existing child) and rebuilt the whole id->path
table, making one append cost O(total children) instead of O(appended).
Appending 250 nodes to a 2750-node container: ~4.5s -> flat ~85ms (pre-4.2.0).

- DashWrapper: children that are the same object reference as the previous
  render skip re-hydration, gated to a settled in-place reconcile only
  (Boolean(renderH) && !newRender.current) so first render / forced remount
  are unaffected.
- patchAnalysis: track pure tail-append eligibility per property (tailAppends);
  any Insert/Prepend/Delete/nested write disqualifies it.
- paths: appendPaths() updates the id->path table for only the appended tail
  slice; used when the patch is a provable pure tail-append.

Tests: 14 new renderer tests (tail-append gating + appendPaths<->computePaths
equivalence incl. pattern-matching ids and nested children). Full suite 50/50.

Stacked on #3881 (fix/remounts); complements #3938 (Patch callback/persistence).
Regression from 4.2.0.
Formatting-only (prettier --write); no logic or assertion changes.
Fixes the private::lint.prettier / private::lint.renderer CI failures.
@T4rk1n
T4rk1n force-pushed the perf/patch-append-rehydration branch from 2e42ee9 to 72a1d8a Compare August 12, 2026 13:37
@sonarqubecloud

Copy link
Copy Markdown

@KoolADE85 KoolADE85 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR does fix top-level appends, but it does not fix nested patch locations.
This example app is speedy on 4.1.0 but on this branch each click gets slower as children accumulate:

from dash import Dash, Input, Output, Patch, callback, html

app = Dash(__name__)

app.layout = html.Div(
    [
        html.Button("Append 250 children", id="btn"),
        html.Span([html.Span([])], id="container"),
    ]
)


@callback(
    Output("container", "children"),
    Input("btn", "n_clicks"),
    prevent_initial_call=True,
)
def append_nested(n):
    p = Patch()
    p[0]["props"]["children"].extend(
        [html.Span(f"[batch {n} child {i}] ") for i in range(250)]
    )
    return p


if __name__ == "__main__":
    app.run(debug=True)

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.

2 participants