Skip to content

Commit d583dcd

Browse files
authored
Merge branch 'dev' into fix/remounts
2 parents 6164e43 + a8bbf98 commit d583dcd

12 files changed

Lines changed: 1406 additions & 89 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
1919
- [#3846](https://github.com/plotly/dash/issues/3846) Fix children returned by a callback being unmounted and remounted on every update instead of reconciled in place, which reset component state and slowed rendering of large subtrees 3-4x (regression introduced in 4.2.0 by [#3570](https://github.com/plotly/dash/pull/3570)). A component passed from a parent is now only remounted when its identity (namespace, type or id) at that position actually changed; the same component with new prop values updates in place, restoring pre-4.2 behavior. To force a remount of a stateful component from a callback, wrap it with `dash.remount` (or return it with a different `id`).
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)).
22+
- [#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)
2223

2324
## [4.4.1] - 2026-07-21
2425

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

Lines changed: 68 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
BackgroundCallbackInfo,
3333
CallbackResponse,
3434
CallbackResponseData,
35+
PatchedOutputs,
3536
SideUpdateOutput
3637
} from '../types/callbacks';
3738
import {isMultiValued, stringifyId, isMultiOutputProp} from './dependencies';
@@ -41,7 +42,8 @@ import {createAction, Action} from 'redux-actions';
4142
import {addHttpHeaders} from '../actions';
4243
import {notifyObservers, updateProps} from './index';
4344
import {CallbackJobPayload} from '../reducers/callbackJobs';
44-
import {parsePatchProps} from './patch';
45+
import {isPatch, parsePatchProps} from './patch';
46+
import {createPatchAnalysis} from './patchAnalysis';
4547
import {computePaths, getPath} from './paths';
4648

4749
import {requestDependencies} from './requestDependencies';
@@ -246,6 +248,47 @@ function cleanOutputProp(property: string) {
246248
return property.split('@')[0];
247249
}
248250

251+
function patchedResultFields(patchedOutputs: PatchedOutputs) {
252+
return keys(patchedOutputs).length ? {patchedOutputs} : {};
253+
}
254+
255+
// When the Layout may have changed, run each output through parsePatchProps against
256+
// the current layout, recording a PatchAnalysis for each output that
257+
// returned a Patch. Shared by the clientside and serverside result paths
258+
function applyPatchedOutputs(
259+
outputs: any,
260+
paths: any,
261+
currentLayout: any,
262+
data: any
263+
) {
264+
const patchedOutputs: PatchedOutputs = {};
265+
flatten(outputs).forEach((out: any) => {
266+
const propName = cleanOutputProp(out.property);
267+
const outputPath = getPath(paths, out.id);
268+
const idStr = stringifyId(out.id);
269+
const dataPath = [idStr, propName];
270+
const outputValue = path(dataPath, data);
271+
if (outputValue === undefined) {
272+
return;
273+
}
274+
if (isPatch(outputValue)) {
275+
// One analysis per output, shared by all of its
276+
// patched props
277+
patchedOutputs[idStr] =
278+
patchedOutputs[idStr] || createPatchAnalysis();
279+
}
280+
const oldProps =
281+
path(outputPath.concat(['props']), currentLayout) || {};
282+
const newProps = parsePatchProps(
283+
{[propName]: outputValue},
284+
oldProps,
285+
patchedOutputs[idStr]
286+
);
287+
data = assocPath(dataPath, newProps[propName], data);
288+
});
289+
return {data, patchedOutputs};
290+
}
291+
249292
async function handleClientside(
250293
dispatch: any,
251294
clientside_function: any,
@@ -962,38 +1005,26 @@ export function executeCallback(
9621005

9631006
if (clientside_function) {
9641007
try {
965-
let data = await handleClientside(
1008+
const data = await handleClientside(
9661009
dispatch,
9671010
clientside_function,
9681011
config,
9691012
payload
9701013
);
971-
// Patch methodology: always run through parsePatchProps for each output
972-
const currentLayout = getState().layout;
973-
flatten(outputs).forEach((out: any) => {
974-
const propName = cleanOutputProp(out.property);
975-
const outputPath = getPath(paths, out.id);
976-
const dataPath = [stringifyId(out.id), propName];
977-
const outputValue = path(dataPath, data);
978-
if (outputValue === undefined) {
979-
return;
980-
}
981-
const oldProps =
982-
path(
983-
outputPath.concat(['props']),
984-
currentLayout
985-
) || {};
986-
const newProps = parsePatchProps(
987-
{[propName]: outputValue},
988-
oldProps
989-
);
990-
data = assocPath(
991-
dataPath,
992-
newProps[propName],
1014+
// Layout may have changed
1015+
// Run every output through parsePatchProps against the current layout
1016+
const {data: patchedData, patchedOutputs} =
1017+
applyPatchedOutputs(
1018+
outputs,
1019+
paths,
1020+
getState().layout,
9931021
data
9941022
);
995-
});
996-
return {data, payload};
1023+
return {
1024+
data: patchedData,
1025+
payload,
1026+
...patchedResultFields(patchedOutputs)
1027+
};
9971028
} catch (error: any) {
9981029
return {error, payload};
9991030
}
@@ -1077,32 +1108,14 @@ export function executeCallback(
10771108
dispatch(addHttpHeaders(newHeaders));
10781109
}
10791110
// Layout may have changed.
1080-
// DRY: Always run through parsePatchProps for each output
1081-
const currentLayout = getState().layout;
1082-
flatten(outputs).forEach((out: any) => {
1083-
const propName = cleanOutputProp(out.property);
1084-
const outputPath = getPath(paths, out.id);
1085-
const dataPath = [stringifyId(out.id), propName];
1086-
const outputValue = path(dataPath, data);
1087-
if (outputValue === undefined) {
1088-
return;
1089-
}
1090-
const oldProps =
1091-
path(
1092-
outputPath.concat(['props']),
1093-
currentLayout
1094-
) || {};
1095-
const newProps = parsePatchProps(
1096-
{[propName]: outputValue},
1097-
oldProps
1098-
);
1099-
1100-
data = assocPath(
1101-
dataPath,
1102-
newProps[propName],
1111+
// Run parsePatchProps against the current layout
1112+
const {data: patchedData, patchedOutputs} =
1113+
applyPatchedOutputs(
1114+
outputs,
1115+
paths,
1116+
getState().layout,
11031117
data
11041118
);
1105-
});
11061119

11071120
if (dynamic_creator) {
11081121
setTimeout(
@@ -1111,7 +1124,11 @@ export function executeCallback(
11111124
);
11121125
}
11131126

1114-
return {data, payload};
1127+
return {
1128+
data: patchedData,
1129+
payload,
1130+
...patchedResultFields(patchedOutputs)
1131+
};
11151132
} catch (res: any) {
11161133
lastError = res;
11171134
if (

dash/dash-renderer/src/actions/dependencies.js

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
resolveDeps
3838
} from './dependencies_ts';
3939
import {computePaths, getPath} from './paths';
40+
import {isCarriedOverByPatch, wasWrittenByPatch} from './patchAnalysis';
4041

4142
import {crawlLayout} from './utils';
4243

@@ -1262,13 +1263,26 @@ export function getWatchedKeys(id, newProps, graphs) {
12621263
* opts.chunkPath: path to the new chunk - used to determine if any outputs are
12631264
* outside of this chunk, because this determines whether inputs inside the
12641265
* chunk count as having changed
1266+
* opts.patchAnalysis: what the `Patch()` operations that produced this chunk
1267+
* changed. Only the components the patch created get their initial call
1268+
* It also allows an input the patch wrote directly to bypass the chunkPath
1269+
* dedup, when that input's own component was carried over (so
1270+
* its own initial call stays suppressed) but downstream callbacks still
1271+
* need to see the new value
1272+
* Absent when the chunk is not the result of a patch
12651273
*
12661274
* Returns an array of objects:
12671275
* {callback, resolvedId, getOutputs, getInputs, getState, ...etc}
12681276
* See getCallbackByOutput for details.
12691277
*/
12701278
export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) {
1271-
const {outputsOnly, removedArrayInputsOnly, newPaths, chunkPath} = opts;
1279+
const {
1280+
outputsOnly,
1281+
removedArrayInputsOnly,
1282+
newPaths,
1283+
chunkPath,
1284+
patchAnalysis
1285+
} = opts;
12721286
const foundCbIds = {};
12731287
const callbacks = [];
12741288

@@ -1316,38 +1330,63 @@ export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) {
13161330

13171331
function handleOneId(id, outIdCallbacks, inIdCallbacks) {
13181332
if (outIdCallbacks) {
1333+
// Suppress the initial call for components a Patch carried over
1334+
// The patch itself tells us which components it created, including
1335+
// components rebuilt with an id that was already in use,
1336+
// whose initial callbacks must run again even if their new defaults
1337+
// happen to match the values of the instance they replaced.
1338+
// It excludes the containers between the patched prop and the value
1339+
// that changed: ramda's assocPath has to rebuild those, but the
1340+
// patch did not create them, so they keep their initial call
1341+
// suppressed
1342+
const isCarryOver = patchAnalysis
1343+
? isCarriedOverByPatch(patchAnalysis, stringifyId(id))
1344+
: false;
13191345
for (const property in outIdCallbacks) {
13201346
const cb = getCallbackByOutput(graphs, paths, id, property);
13211347
if (cb) {
13221348
// callbacks found in the layout by output should always run
13231349
// unless specifically requested not to.
13241350
// ie this is the initial call of this callback even if it's
13251351
// not the page initialization but just a new layout chunk
1326-
if (!cb.callback.prevent_initial_call) {
1352+
if (!cb.callback.prevent_initial_call && !isCarryOver) {
13271353
cb.initialCall = true;
13281354
addCallback(cb);
13291355
}
13301356
}
13311357
}
13321358
}
13331359
if (!outputsOnly && inIdCallbacks) {
1360+
const idStr = stringifyId(id);
13341361
const maybeAddCallback = removedArrayInputsOnly
1335-
? addCallbackIfArray(stringifyId(id))
1362+
? addCallbackIfArray(idStr)
13361363
: addCallback;
1337-
let handleThisCallback = maybeAddCallback;
1338-
if (chunkPath) {
1339-
handleThisCallback = cb => {
1340-
if (
1341-
!all(
1342-
startsWith(chunkPath),
1343-
pluck('path', flatten(cb.getOutputs(paths)))
1344-
)
1345-
) {
1346-
maybeAddCallback(cb);
1347-
}
1348-
};
1349-
}
13501364
for (const property in inIdCallbacks) {
1365+
// A callback, whose outputs are all inside the chunk, is
1366+
// normally dropped here on the assumption that the
1367+
// output handling above already covers it
1368+
// That assumption fails when the patch
1369+
// wrote a new value directly on this input without
1370+
// recreating the input's own component
1371+
// The output side stays suppressed because the output
1372+
// component was carried over, but this input's value
1373+
// genuinely changed, so the callback must still be added
1374+
let handleThisCallback = maybeAddCallback;
1375+
if (
1376+
chunkPath &&
1377+
!wasWrittenByPatch(patchAnalysis, idStr, property)
1378+
) {
1379+
handleThisCallback = cb => {
1380+
if (
1381+
!all(
1382+
startsWith(chunkPath),
1383+
pluck('path', flatten(cb.getOutputs(paths)))
1384+
)
1385+
) {
1386+
maybeAddCallback(cb);
1387+
}
1388+
};
1389+
}
13511390
getCallbacksByInput(
13521391
graphs,
13531392
paths,

0 commit comments

Comments
 (0)