Fix O(total) Patch().append() slowdown into a growing container - #3948
Open
T4rk1n wants to merge 2 commits into
Open
Fix O(total) Patch().append() slowdown into a growing container#3948T4rk1n wants to merge 2 commits into
T4rk1n wants to merge 2 commits into
Conversation
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
force-pushed
the
perf/patch-append-rehydration
branch
from
August 12, 2026 13:37
2e42ee9 to
72a1d8a
Compare
|
KoolADE85
requested changes
Aug 13, 2026
KoolADE85
left a comment
Contributor
There was a problem hiding this comment.
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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:DashWrapperre-hydrated the entire children array — re-runningRegistry.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).computePathsrebuilt 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); anyInsert/Prepend/Delete/Reverse/nested write disqualifies it.paths.js—appendPaths()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)
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) andappendPaths↔computePathsequivalence, 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 tofix/remounts; retarget todevonce #3881 merges.