Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- 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.
- [#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)).
- [#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)
- 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.

## [4.4.1] - 2026-07-21

Expand Down
58 changes: 54 additions & 4 deletions dash/dash-renderer/src/actions/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,17 +364,66 @@ function recordWrittenProp(
}
}

/*
* Operations that only add items at the end of a list, and how many items
* each one adds. `location` is the list they append to. Anything else that
* touches a list (Insert, Prepend, Delete, Remove, Clear, Reverse, Assign)
* invalidates the append-only shortcut for that property, since old items can
* no longer be assumed to have kept their indices.
*/
const tailAppendCounts: {[operation: string]: (params: any) => number} = {
Append: () => 1,
Extend: params => (Array.isArray(params.value) ? params.value.length : 0)
};

function recordTailAppend(
property: string | undefined,
location: LocationIndex[],
operation: string,
params: any,
analysis: PatchAnalysis
) {
if (property === undefined) {
return;
}
const existing = analysis.tailAppends[property];
if (existing === false) {
// Already invalidated for this property; nothing can undo that.
return;
}
if (operation in tailAppendCounts) {
const count = tailAppendCounts[operation](params);
if (existing === undefined) {
// First append to this property: remember which list it grew.
analysis.tailAppends[property] = {location, count};
return;
}
if (equals(existing.location, location)) {
// Another append to the same list - still a pure single-list
// append, just more items.
existing.count += count;
return;
}
// Appended to a second, different list. We can only express one
// grown list per property, so fall back to a full recompute.
}
analysis.tailAppends[property] = false;
}

function recordPatchOperation(
previous: any,
patchOperation: PatchOperation,
analysis: PatchAnalysis
analysis: PatchAnalysis,
property?: string
) {
const {operation, location, params} = patchOperation;

if (insertingOperations[operation]) {
collectComponentIds(params.value, analysis.freshIds, new Set());
}

recordTailAppend(property, location, operation, params, analysis);

if (operation === 'Merge' && params.value && is(Object, params.value)) {
Object.keys(params.value).forEach(key =>
recordWrittenProp(
Expand All @@ -392,7 +441,8 @@ function recordPatchOperation(
export function handlePatch<T>(
previousValue: T,
patchValue: any,
analysis?: PatchAnalysis
analysis?: PatchAnalysis,
property?: string
): T {
let reducedValue = previousValue;

Expand All @@ -404,7 +454,7 @@ export function handlePatch<T>(
throw new Error(`Invalid Operation ${patch.operation}`);
}
if (analysis) {
recordPatchOperation(reducedValue, patch, analysis);
recordPatchOperation(reducedValue, patch, analysis, property);
}
reducedValue = handler(reducedValue, patch);
}
Expand Down Expand Up @@ -438,7 +488,7 @@ export function parsePatchProps(
if (analysis) {
analysis.patchedProps[key] = true;
}
patchedProps[key] = handlePatch(previousValue, val, analysis);
patchedProps[key] = handlePatch(previousValue, val, analysis, key);
} else {
patchedProps[key] = val;
}
Expand Down
49 changes: 48 additions & 1 deletion dash/dash-renderer/src/actions/patchAnalysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,32 @@ export type PatchAnalysis = {
freshIds: {[idStr: string]: true};
/* Props the patch wrote on components that already existed. */
writtenProps: {[idStr: string]: {[property: string]: true}};
/*
* For a property whose value is a list somewhere in its tree: where the
* patch appended and how many items it added, if appending to one single
* list is *all* this patch did.
*
* `location` is the path, within the property's value, of the list that
* grew (`[]` when the property's value is itself the list, e.g. a plain
* `children`; `[0, 'props', 'children']` for a nested
* `p[0]['props']['children'].extend(...)`). `count` is the total number of
* items appended to that list.
*
* `false` means the patch is not a pure single-list append - it did
* something else to a list (Insert/Prepend/Delete/Remove/Clear/Reverse/
* Assign), or appended to more than one distinct list. Either way the old
* items can no longer be assumed to have kept their positions, so the
* property is not eligible for the append-only paths shortcut.
*/
tailAppends: {
[property: string]:
| {location: (string | number)[]; count: number}
| false;
};
};

export function createPatchAnalysis(): PatchAnalysis {
return {patchedProps: {}, freshIds: {}, writtenProps: {}};
return {patchedProps: {}, freshIds: {}, writtenProps: {}, tailAppends: {}};
}

/*
Expand Down Expand Up @@ -111,3 +133,28 @@ export function wasWrittenByPatch(
}
return Boolean(analysis.writtenProps[idStr]?.[property]);
}

/*
* Where and how much this patch appended, if appending to one single list
* (possibly nested) is *all* it did to `property` - what paths.js needs to
* compute paths for only the new items instead of re-crawling every
* pre-existing one. `null` when the analysis doesn't cover this property, or
* when the patch touched a list in some other way (see `tailAppends`).
*/
export function tailAppend(
analysis: PatchAnalysis | undefined,
property: string
): {location: (string | number)[]; count: number} | null {
const entry = analysis?.tailAppends[property];
return entry && typeof entry === 'object' ? entry : null;
}

/*
* How many items `tailAppend` reports for `property` (0 when it reports none).
*/
export function tailAppendCount(
analysis: PatchAnalysis | undefined,
property: string
): number {
return tailAppend(analysis, property)?.count ?? 0;
}
45 changes: 45 additions & 0 deletions dash/dash-renderer/src/actions/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,51 @@
return {strs, objs, events: events || oldPaths.events};
}

/*
* Fast path for a Patch that only appended items to the tail of a children
* list: instead of re-crawling every pre-existing child to rebuild the whole
* id->path table (O(total children)), compute paths only for `newItems`
* (the tail slice the patch added) and layer them onto the existing table.
* The pre-existing entries are valid as-is because an append-only patch
* never changes the position or identity of the items that were already
* there (see `tailAppends` in patchAnalysis.ts, which guarantees this before
* this function is used).
*/
export function appendPaths(newItems, startingPath, appendOffset, oldPaths) {
const strs = {...oldPaths.strs};
const objs = {...oldPaths.objs};
const newObjItems = {};

newItems.forEach((child, i) => {
crawlLayout(child, (c, itempath) => {
const id = path(['props', 'id'], c);
if (!id) {
return;
}
const fullPath = concat(startingPath, [appendOffset + i]).concat(
itempath
);
if (typeof id === 'object') {
const keys = Object.keys(id).sort();

Check failure on line 95 in dash/dash-renderer/src/actions/paths.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide a compare function that depends on "String.localeCompare", to reliably sort elements alphabetically.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ_2J0EZb8ENv9FowX4k&open=AZ_2J0EZb8ENv9FowX4k&pullRequest=3948
const values = props(keys, id);
const keyStr = keys.join(',');
(newObjItems[keyStr] = newObjItems[keyStr] || []).push({

Check warning on line 98 in dash/dash-renderer/src/actions/paths.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract the assignment of "newObjItems[keyStr]" from this expression.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AZ_2J0EZb8ENv9FowX4l&open=AZ_2J0EZb8ENv9FowX4l&pullRequest=3948
values,
path: fullPath
});
} else {
strs[id] = fullPath;
}
});
});

Object.keys(newObjItems).forEach(keyStr => {
objs[keyStr] = concat(objs[keyStr] || [], newObjItems[keyStr]);
});

return {strs, objs, events: oldPaths.events};
}

export function getPath(paths, id) {
if (typeof id === 'object') {
const keys = Object.keys(id).sort();
Expand Down
126 changes: 96 additions & 30 deletions dash/dash-renderer/src/observers/executedCallbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,12 @@
import {ICallback, IStoredCallback} from '../types/callbacks';

import {updateProps, setPaths, handleAsyncError} from '../actions';
import {getPath, computePaths} from '../actions/paths';
import {getPath, computePaths, appendPaths} from '../actions/paths';
import {
PatchAnalysis,
analysisForAllProps,
analysisForProp
analysisForProp,
tailAppend
} from '../actions/patchAnalysis';

import {applyPersistence, prunePersistence} from '../persistence';
Expand Down Expand Up @@ -175,14 +176,59 @@
oldChildren: any,
oldChildrenPath: any[],
filterRoot: any = false,
propAnalysis?: PatchAnalysis
propAnalysis?: PatchAnalysis,
append: {
location: (string | number)[];
count: number;
} | null = null
) => {
const oPaths = getState().paths;
const paths = computePaths(
children,
oldChildrenPath,
oPaths
);

// If this patch's only structural change was
// appending items to the tail of one list (tracked
// by patchAnalysis.tailAppends), the pre-existing
// children kept their positions and identities.
// Compute paths only for the new tail slice instead
// of re-crawling the whole array - the dominant cost
// of a repeated Patch().append() into a large
// container. `location` points at the grown list,
// which may be nested (`[]` for a plain `children`,
// `[0, 'props', 'children']` for a nested extend).
const newList = append
? append.location.length
? path(append.location, children)
: children

Check warning on line 200 in dash/dash-renderer/src/observers/executedCallbacks.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaAVj0aL_yMfBe7wkL0p&open=AaAVj0aL_yMfBe7wkL0p&pullRequest=3948
: undefined;
const oldList = append
? append.location.length
? path(append.location, oldChildren)
: oldChildren

Check warning on line 205 in dash/dash-renderer/src/observers/executedCallbacks.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=plotly_dash&issues=AaAVj0aL_yMfBe7wkL0q&open=AaAVj0aL_yMfBe7wkL0q&pullRequest=3948
: undefined;
const isTailAppend =
!!append &&
append.count > 0 &&
Array.isArray(newList) &&
Array.isArray(oldList) &&
newList.length ===
oldList.length + append.count;

const paths =
isTailAppend && append
? appendPaths(
(newList as any[]).slice(
(oldList as any[]).length
),
oldChildrenPath.concat(
append.location
),
(oldList as any[]).length,
oPaths
)
: computePaths(
children,
oldChildrenPath,
oPaths
);
dispatch(setPaths(paths));

// Get callbacks for new layout (w/ execution group)
Expand All @@ -199,24 +245,29 @@
);

// Wildcard callbacks with array inputs (ALL / ALLSMALLER) need to trigger
// even due to the deletion of components
requestedCallbacks = concat(
requestedCallbacks,
getLayoutCallbacks(
graphs,
oldPaths,
oldChildren,
{
removedArrayInputsOnly: true,
newPaths: paths,
chunkPath: oldChildrenPath,
filterRoot
}
).map(rcb => ({
...rcb,
predecessors
}))
);
// even due to the deletion of components.
// A tail append never removes anything, so oldChildren
// is unchanged and this pass can only find what it
// found last time (nothing new) - skip the crawl.
if (!isTailAppend) {
requestedCallbacks = concat(
requestedCallbacks,
getLayoutCallbacks(
graphs,
oldPaths,
oldChildren,
{
removedArrayInputsOnly: true,
newPaths: paths,
chunkPath: oldChildrenPath,
filterRoot
}
).map(rcb => ({
...rcb,
predecessors
}))
);
}
};

let recomputed = false;
Expand Down Expand Up @@ -283,15 +334,30 @@
oldLayout
);

const childrenPropAnalysis =
analysisForProp(
patchAnalysis,
childrenPropPath[0]
);

handlePaths(
children,
oldChildren,
oldChildrenPath,
false,
analysisForProp(
patchAnalysis,
childrenPropPath[0]
)
childrenPropAnalysis,
// `tailAppends` locations are relative
// to the top-level property's value, so
// the shortcut only applies when
// `children` *is* that value (a plain
// `children`), not a dotted sub-path
// (`figure.data`) into it.
childrenPropPath.length === 1
? tailAppend(
childrenPropAnalysis,
childrenPropPath[0]
)
: null
);
}
});
Expand Down
Loading
Loading