Skip to content

Commit 15180ec

Browse files
committed
Extend Patch append perf fix to nested and non-tail locations
The O(appended) append fast-path only recognized appends at the top level of a patched property, so a nested `p[0]['props']['children'].extend(...)` fell back to the O(total-children) re-hydrate + path-table rebuild on every click - the exact slowdown the top-level fix removed (reported in review of #3948). Appending 250 nodes into a growing nested container went from a flat ~0.2s back up to ~8.6s at 3000 children. - patchAnalysis: `tailAppends` now records `{location, count}` instead of a bare count, so an append to a single nested list stays eligible. Appends to two different lists, or any reorder/insert/delete, still disqualify it. - executedCallbacks: the incremental path-table update targets `oldChildrenPath + location` and slices the nested array. Gated to the plain property value (not a dotted `figure.data` sub-path). - DashWrapper: the item-by-item ref-skip now runs during a fresh, non-remount render too, not only a settled reconcile. An ancestor rebuilt by assocPath on the path down to a deep append still lets its `concat`-preserved children reconcile in place instead of re-hydrating the whole list. A real remount (identity change / dash.remount) still rebuilds everything. This half is operation-agnostic, so a nested scalar Assign/arithmetic/Merge into a large container is now O(1) re-hydration too, at parity with append. Index-shifting ops (Prepend/Insert-mid/Delete/Remove/Reverse) remain O(shifted) by nature - they genuinely move pre-existing items. Tests: nested tail-append detection + multi-list/nested-disruption disqualify cases in patch.test.js; nested-location appendPaths<->computePaths equivalence in paths.test.js. Renderer unit 55/55; integration test_patch, test_clientside_patch, test_redraw, test_children_reorder green.
1 parent 72a1d8a commit 15180ec

7 files changed

Lines changed: 292 additions & 69 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
2020
- Fix components rendered as props (eg. component labels in `dcc.Dropdown` options, `dcc.Tab` labels) crashing with "can't access property 'props', layout is undefined" or failing to update when the host component's subtree was replaced by a callback. Components inserted out of the layout tree via `ExternalWrapper` now re-insert themselves when their layout entry was removed, so they update in place instead of updating a stale path.
2121
- [#3929](https://github.com/plotly/dash/issues/3929) Fix components that set their own initial state on mount (eg. `dash-bootstrap-components` `Tabs`, which selects its default active tab) not applying that state on first render - a component's descendant layout hashes were reset on its very first fresh render, discarding the mount-time update before it took effect. The reset now only runs from the second fresh render onward, so a component's initial state survives (regression introduced in 4.2.0 by [#3570](https://github.com/plotly/dash/pull/3570)).
2222
- [#3938](https://github.com/plotly/dash/pull/3938) Fix `dcc.Patch()` re-running the initial callbacks of components that were already on the page, including every matching (`MATCH`/`ALL`) element, and wiping their user-edited persisted values. Fixes [#3681](https://github.com/plotly/dash/issues/3681) and [#3937](https://github.com/plotly/dash/issues/3937)
23-
- Fix `dcc.Patch().append()` (and `.extend()`) into a growing container getting progressively slower as the container fills — 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 a single append cost O(total children) instead of O(appended). Appending 250 nodes to a 2750-node container dropped from ~4.5s back to a flat ~85ms, matching pre-4.2.0 behavior. Children that are the same object reference as the previous render now skip re-hydration (only for a settled component reconciling in place, never a first render or a forced remount), and pure tail-appends update the path table incrementally. Any change that reorders, inserts, replaces or writes a child still re-renders it normally.
23+
- Fix `dcc.Patch().append()` (and `.extend()`) into a growing container getting progressively slower as the container fills — 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 a single append cost O(total children) instead of O(appended). Appending 250 nodes to a 3000-node container dropped from ~8.6s back to a flat ~0.2s, matching pre-4.2.0 behavior. This now also covers appending to a *nested* list (eg. `p[0]['props']['children'].extend(...)`), which previously fell back to the full re-hydrate. Children that are the same object reference as the previous render skip re-hydration (unless the component is being remounted), and pure tail-appends — at any depth — update the path table incrementally. Any change that reorders, inserts, replaces or writes a child still re-renders it normally.
2424

2525
## [4.4.1] - 2026-07-21
2626

dash/dash-renderer/src/actions/patch.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -366,10 +366,10 @@ function recordWrittenProp(
366366

367367
/*
368368
* Operations that only add items at the end of a list, and how many items
369-
* each one adds. Anything else that touches a list's top level (Insert,
370-
* Prepend, Delete, Remove, Clear, Reverse, Assign) invalidates the
371-
* append-only shortcut for that property, since old items can no longer be
372-
* assumed to have kept their indices.
369+
* each one adds. `location` is the list they append to. Anything else that
370+
* touches a list (Insert, Prepend, Delete, Remove, Clear, Reverse, Assign)
371+
* invalidates the append-only shortcut for that property, since old items can
372+
* no longer be assumed to have kept their indices.
373373
*/
374374
const tailAppendCounts: {[operation: string]: (params: any) => number} = {
375375
Append: () => 1,
@@ -386,15 +386,26 @@ function recordTailAppend(
386386
if (property === undefined) {
387387
return;
388388
}
389-
if (analysis.tailAppends[property] === false) {
389+
const existing = analysis.tailAppends[property];
390+
if (existing === false) {
390391
// Already invalidated for this property; nothing can undo that.
391392
return;
392393
}
393-
if (location.length === 0 && operation in tailAppendCounts) {
394-
analysis.tailAppends[property] =
395-
(analysis.tailAppends[property] || 0) +
396-
tailAppendCounts[operation](params);
397-
return;
394+
if (operation in tailAppendCounts) {
395+
const count = tailAppendCounts[operation](params);
396+
if (existing === undefined) {
397+
// First append to this property: remember which list it grew.
398+
analysis.tailAppends[property] = {location, count};
399+
return;
400+
}
401+
if (equals(existing.location, location)) {
402+
// Another append to the same list - still a pure single-list
403+
// append, just more items.
404+
existing.count += count;
405+
return;
406+
}
407+
// Appended to a second, different list. We can only express one
408+
// grown list per property, so fall back to a full recompute.
398409
}
399410
analysis.tailAppends[property] = false;
400411
}

dash/dash-renderer/src/actions/patchAnalysis.ts

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,27 @@ export type PatchAnalysis = {
3232
/* Props the patch wrote on components that already existed. */
3333
writtenProps: {[idStr: string]: {[property: string]: true}};
3434
/*
35-
* For a property whose value is a list: how many items were added at the
36-
* tail (Append/Extend, at the top level of that property's value) by this
37-
* patch, if that's *all* this patch did to the list. `false` means the
38-
* patch also did something else to it (Insert/Prepend/Delete/Remove/
39-
* Clear/Reverse/Assign, or wrote into a nested location) - the old items
40-
* can no longer be assumed to have kept their positions, so the property
41-
* is not eligible for the append-only paths shortcut.
35+
* For a property whose value is a list somewhere in its tree: where the
36+
* patch appended and how many items it added, if appending to one single
37+
* list is *all* this patch did.
38+
*
39+
* `location` is the path, within the property's value, of the list that
40+
* grew (`[]` when the property's value is itself the list, e.g. a plain
41+
* `children`; `[0, 'props', 'children']` for a nested
42+
* `p[0]['props']['children'].extend(...)`). `count` is the total number of
43+
* items appended to that list.
44+
*
45+
* `false` means the patch is not a pure single-list append - it did
46+
* something else to a list (Insert/Prepend/Delete/Remove/Clear/Reverse/
47+
* Assign), or appended to more than one distinct list. Either way the old
48+
* items can no longer be assumed to have kept their positions, so the
49+
* property is not eligible for the append-only paths shortcut.
4250
*/
43-
tailAppends: {[property: string]: number | false};
51+
tailAppends: {
52+
[property: string]:
53+
| {location: (string | number)[]; count: number}
54+
| false;
55+
};
4456
};
4557

4658
export function createPatchAnalysis(): PatchAnalysis {
@@ -123,16 +135,26 @@ export function wasWrittenByPatch(
123135
}
124136

125137
/*
126-
* How many items this patch appended to the tail of `property`'s list, if
127-
* appending (Append/Extend) is *all* it did to that list - the shortcut
128-
* paths.js needs to compute paths only for the new items instead of
129-
* re-crawling every pre-existing one. 0 when the analysis doesn't cover this
130-
* property, or when the patch touched the list in some other way.
138+
* Where and how much this patch appended, if appending to one single list
139+
* (possibly nested) is *all* it did to `property` - what paths.js needs to
140+
* compute paths for only the new items instead of re-crawling every
141+
* pre-existing one. `null` when the analysis doesn't cover this property, or
142+
* when the patch touched a list in some other way (see `tailAppends`).
143+
*/
144+
export function tailAppend(
145+
analysis: PatchAnalysis | undefined,
146+
property: string
147+
): {location: (string | number)[]; count: number} | null {
148+
const entry = analysis?.tailAppends[property];
149+
return entry && typeof entry === 'object' ? entry : null;
150+
}
151+
152+
/*
153+
* How many items `tailAppend` reports for `property` (0 when it reports none).
131154
*/
132155
export function tailAppendCount(
133156
analysis: PatchAnalysis | undefined,
134157
property: string
135158
): number {
136-
const count = analysis?.tailAppends[property];
137-
return typeof count === 'number' ? count : 0;
159+
return tailAppend(analysis, property)?.count ?? 0;
138160
}

dash/dash-renderer/src/observers/executedCallbacks.ts

Lines changed: 59 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
PatchAnalysis,
4242
analysisForAllProps,
4343
analysisForProp,
44-
tailAppendCount
44+
tailAppend
4545
} from '../actions/patchAnalysis';
4646

4747
import {applyPersistence, prunePersistence} from '../persistence';
@@ -177,37 +177,58 @@ const observer: IStoreObserverDefinition<IStoreState> = {
177177
oldChildrenPath: any[],
178178
filterRoot: any = false,
179179
propAnalysis?: PatchAnalysis,
180-
appendedCount = 0
180+
append: {
181+
location: (string | number)[];
182+
count: number;
183+
} | null = null
181184
) => {
182185
const oPaths = getState().paths;
183186

184187
// If this patch's only structural change was
185-
// appending items to the tail (tracked by
186-
// patchAnalysis.tailAppends), the pre-existing
188+
// appending items to the tail of one list (tracked
189+
// by patchAnalysis.tailAppends), the pre-existing
187190
// children kept their positions and identities.
188-
// Compute paths only for the new tail slice
189-
// instead of re-crawling the whole array - the
190-
// dominant cost of a repeated Patch().append()
191-
// into a large container.
191+
// Compute paths only for the new tail slice instead
192+
// of re-crawling the whole array - the dominant cost
193+
// of a repeated Patch().append() into a large
194+
// container. `location` points at the grown list,
195+
// which may be nested (`[]` for a plain `children`,
196+
// `[0, 'props', 'children']` for a nested extend).
197+
const newList = append
198+
? append.location.length
199+
? path(append.location, children)
200+
: children
201+
: undefined;
202+
const oldList = append
203+
? append.location.length
204+
? path(append.location, oldChildren)
205+
: oldChildren
206+
: undefined;
192207
const isTailAppend =
193-
appendedCount > 0 &&
194-
Array.isArray(children) &&
195-
Array.isArray(oldChildren) &&
196-
children.length ===
197-
oldChildren.length + appendedCount;
198-
199-
const paths = isTailAppend
200-
? appendPaths(
201-
children.slice(oldChildren.length),
202-
oldChildrenPath,
203-
oldChildren.length,
204-
oPaths
205-
)
206-
: computePaths(
207-
children,
208-
oldChildrenPath,
209-
oPaths
210-
);
208+
!!append &&
209+
append.count > 0 &&
210+
Array.isArray(newList) &&
211+
Array.isArray(oldList) &&
212+
newList.length ===
213+
oldList.length + append.count;
214+
215+
const paths =
216+
isTailAppend && append
217+
? appendPaths(
218+
(newList as any[]).slice(
219+
(oldList as any[]).length
220+
),
221+
oldChildrenPath.concat(
222+
append.location
223+
),
224+
(oldList as any[]).length,
225+
oPaths
226+
)
227+
: computePaths(
228+
children,
229+
oldChildrenPath,
230+
oPaths
231+
);
211232
dispatch(setPaths(paths));
212233

213234
// Get callbacks for new layout (w/ execution group)
@@ -325,10 +346,18 @@ const observer: IStoreObserverDefinition<IStoreState> = {
325346
oldChildrenPath,
326347
false,
327348
childrenPropAnalysis,
328-
tailAppendCount(
329-
childrenPropAnalysis,
330-
childrenPropPath[0]
331-
)
349+
// `tailAppends` locations are relative
350+
// to the top-level property's value, so
351+
// the shortcut only applies when
352+
// `children` *is* that value (a plain
353+
// `children`), not a dotted sub-path
354+
// (`figure.data`) into it.
355+
childrenPropPath.length === 1
356+
? tailAppend(
357+
childrenPropAnalysis,
358+
childrenPropPath[0]
359+
)
360+
: null
332361
);
333362
}
334363
});

dash/dash-renderer/src/wrapper/DashWrapper.tsx

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,10 @@ function DashWrapper({
8787
const memoizedKeys: MutableRefObject<MemoizedKeysType> = useRef({});
8888
const newRender = useRef(false);
8989
const freshRenders = useRef(0);
90+
// Did *this* render remount the subtree (identity changed or an explicit
91+
// dash.remount())? A remount rebuilds every descendant from scratch, so
92+
// the item-by-item ref-skip below must not carry anything over across it.
93+
const remountedThisRender = useRef(false);
9094
const renderedIdentity: MutableRefObject<string | null> = useRef(null);
9195
const hasFreshRendered = useRef(false);
9296
const renderedPath = useRef<DashLayoutPath>(componentPath);
@@ -128,11 +132,13 @@ function DashWrapper({
128132
// a remount even when the identity is unchanged, letting a
129133
// callback explicitly reset a component's internal state.
130134
const identity = componentIdentity(_passedComponent);
131-
if (
135+
const isRemount = Boolean(
132136
_passedComponent?._dashprivate_remount ||
133-
(renderedIdentity.current !== null &&
134-
renderedIdentity.current !== identity)
135-
) {
137+
(renderedIdentity.current !== null &&
138+
renderedIdentity.current !== identity)
139+
);
140+
remountedThisRender.current = isRemount;
141+
if (isRemount) {
136142
freshRenders.current += 1;
137143
}
138144
renderedIdentity.current = identity;
@@ -141,6 +147,7 @@ function DashWrapper({
141147
}
142148
} else {
143149
newRender.current = false;
150+
remountedThisRender.current = false;
144151
}
145152
renderedPath.current = componentPath;
146153
}, [_newRender]);
@@ -244,8 +251,12 @@ function DashWrapper({
244251
// An item that's the exact same object as before
245252
// didn't change - not even props a shallow patch
246253
// analysis might miss - so it doesn't need to be
247-
// forced into a fresh hydrate just because this
248-
// array as a whole did (eg. a sibling got added).
254+
// forced into a fresh hydrate just because this array
255+
// as a whole did (eg. a sibling got appended, or a
256+
// deeper list this item sits above grew: ramda's
257+
// assocPath rebuilds the array and this item's parent
258+
// on the way to the change, but `concat` keeps the
259+
// pre-existing items themselves the same object).
249260
const unchanged = allowRefSkip && prevArray?.[i] === n;
250261
return createContainer(
251262
n,
@@ -542,11 +553,16 @@ function DashWrapper({
542553
!renderH || newRender.current || 'children' in changedProps
543554
? {}
544555
: 0,
545-
// Only worth checking item-by-item when this component
546-
// itself isn't already getting a fresh hydrate (first
547-
// render / forced remount already means "treat everything
548-
// below as new", so there's nothing to skip).
549-
Boolean(renderH) && !newRender.current
556+
// Check items one by one against the previous render whenever
557+
// we're not remounting. Even a component getting a fresh
558+
// hydrate (eg. a Patch appended a deep sibling, so its parent
559+
// was rebuilt on the path down) can keep the pre-existing
560+
// items - the exact same objects - reconciling in place
561+
// instead of re-hydrating the whole list every time. A first
562+
// render simply has no previous array to match, and a remount
563+
// must rebuild everything, so both fall through to a fresh
564+
// hydrate.
565+
!remountedThisRender.current
550566
);
551567
}
552568
newRender.current = false;

0 commit comments

Comments
 (0)