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 @@ -14,6 +14,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
- [#3916](https://github.com/plotly/dash/pull/3916) Fixed a regression where dragging multiple files into `dcc.Upload` would upload only the first file when `multiple=True`
- [#3922](https://github.com/plotly/dash/pull/3922) Fix `dcc.Input(type="number")` stepper behavior when only `min` is set.
- [#3925](https://github.com/plotly/dash/pull/3925) Use the proxied url as the Jupyter server url so `DASH_PROXY` is honored by the external url and inline iframe in notebooks.
- [#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)

## [4.4.1] - 2026-07-21

Expand Down
131 changes: 76 additions & 55 deletions dash/dash-renderer/src/actions/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
BackgroundCallbackInfo,
CallbackResponse,
CallbackResponseData,
PatchedOutputs,
SideUpdateOutput
} from '../types/callbacks';
import {isMultiValued, stringifyId, isMultiOutputProp} from './dependencies';
Expand All @@ -41,7 +42,8 @@ import {createAction, Action} from 'redux-actions';
import {addHttpHeaders} from '../actions';
import {notifyObservers, updateProps} from './index';
import {CallbackJobPayload} from '../reducers/callbackJobs';
import {parsePatchProps} from './patch';
import {isPatch, parsePatchProps} from './patch';
import {createPatchAnalysis} from './patchAnalysis';
import {computePaths, getPath} from './paths';

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

function patchedResultFields(patchedOutputs: PatchedOutputs) {
return keys(patchedOutputs).length ? {patchedOutputs} : {};
}

// When the Layout may have changed, run each output through parsePatchProps against
// the current layout, recording a PatchAnalysis for each output that
// returned a Patch. Shared by the clientside and serverside result paths
function applyPatchedOutputs(
outputs: any,
paths: any,
currentLayout: any,
data: any
) {
const patchedOutputs: PatchedOutputs = {};
flatten(outputs).forEach((out: any) => {
const propName = cleanOutputProp(out.property);
const outputPath = getPath(paths, out.id);
const idStr = stringifyId(out.id);
const dataPath = [idStr, propName];
const outputValue = path(dataPath, data);
if (outputValue === undefined) {
return;
}
if (isPatch(outputValue)) {
// One analysis per output, shared by all of its
// patched props
patchedOutputs[idStr] =
patchedOutputs[idStr] || createPatchAnalysis();
}
const oldProps =
path(outputPath.concat(['props']), currentLayout) || {};
const newProps = parsePatchProps(
{[propName]: outputValue},
oldProps,
patchedOutputs[idStr]
);
data = assocPath(dataPath, newProps[propName], data);
});
return {data, patchedOutputs};
}

async function handleClientside(
dispatch: any,
clientside_function: any,
Expand Down Expand Up @@ -962,38 +1005,28 @@ export function executeCallback(

if (clientside_function) {
try {
let data = await handleClientside(
const data = await handleClientside(
dispatch,
clientside_function,
config,
payload
);
// Patch methodology: always run through parsePatchProps for each output
const currentLayout = getState().layout;
flatten(outputs).forEach((out: any) => {
const propName = cleanOutputProp(out.property);
const outputPath = getPath(paths, out.id);
const dataPath = [stringifyId(out.id), propName];
const outputValue = path(dataPath, data);
if (outputValue === undefined) {
return;
}
const oldProps =
path(
outputPath.concat(['props']),
currentLayout
) || {};
const newProps = parsePatchProps(
{[propName]: outputValue},
oldProps
);
data = assocPath(
dataPath,
newProps[propName],
data
);
});
return {data, payload};
// Layout may have changed
// Run every output through parsePatchProps against the current layout
const {
data: patchedData,
patchedOutputs
} = applyPatchedOutputs(
outputs,
paths,
getState().layout,
data
);
return {
data: patchedData,
payload,
...patchedResultFields(patchedOutputs)
};
} catch (error: any) {
return {error, payload};
}
Expand Down Expand Up @@ -1077,32 +1110,16 @@ export function executeCallback(
dispatch(addHttpHeaders(newHeaders));
}
// Layout may have changed.
// DRY: Always run through parsePatchProps for each output
const currentLayout = getState().layout;
flatten(outputs).forEach((out: any) => {
const propName = cleanOutputProp(out.property);
const outputPath = getPath(paths, out.id);
const dataPath = [stringifyId(out.id), propName];
const outputValue = path(dataPath, data);
if (outputValue === undefined) {
return;
}
const oldProps =
path(
outputPath.concat(['props']),
currentLayout
) || {};
const newProps = parsePatchProps(
{[propName]: outputValue},
oldProps
);

data = assocPath(
dataPath,
newProps[propName],
data
);
});
// Run parsePatchProps against the current layout
const {
data: patchedData,
patchedOutputs
} = applyPatchedOutputs(
outputs,
paths,
getState().layout,
data
);

if (dynamic_creator) {
setTimeout(
Expand All @@ -1111,7 +1128,11 @@ export function executeCallback(
);
}

return {data, payload};
return {
data: patchedData,
payload,
...patchedResultFields(patchedOutputs)
};
} catch (res: any) {
lastError = res;
if (
Expand Down
26 changes: 24 additions & 2 deletions dash/dash-renderer/src/actions/dependencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
resolveDeps
} from './dependencies_ts';
import {computePaths, getPath} from './paths';
import {isCarriedOverByPatch} from './patchAnalysis';

import {crawlLayout} from './utils';

Expand Down Expand Up @@ -1262,13 +1263,22 @@ export function getWatchedKeys(id, newProps, graphs) {
* opts.chunkPath: path to the new chunk - used to determine if any outputs are
* outside of this chunk, because this determines whether inputs inside the
* chunk count as having changed
* opts.patchAnalysis: what the `Patch()` operations that produced this chunk
* changed. Only the components the patch created get their initial call
* Absent when the chunk is not the result of a patch
*
* Returns an array of objects:
* {callback, resolvedId, getOutputs, getInputs, getState, ...etc}
* See getCallbackByOutput for details.
*/
export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) {
const {outputsOnly, removedArrayInputsOnly, newPaths, chunkPath} = opts;
const {
outputsOnly,
removedArrayInputsOnly,
newPaths,
chunkPath,
patchAnalysis
} = opts;
const foundCbIds = {};
const callbacks = [];

Expand Down Expand Up @@ -1316,14 +1326,26 @@ export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) {

function handleOneId(id, outIdCallbacks, inIdCallbacks) {
if (outIdCallbacks) {
// Suppress the initial call for components a Patch carried over
// The patch itself tells us which components it created, including
// components rebuilt with an id that was already in use,
// whose initial callbacks must run again even if their new defaults
// happen to match the values of the instance they replaced.
// It excludes the containers between the patched prop and the value
// that changed: ramda's assocPath has to rebuild those, but the
// patch did not create them, so they keep their initial call
// suppressed
const isCarryOver = patchAnalysis
? isCarriedOverByPatch(patchAnalysis, stringifyId(id))
: false;
for (const property in outIdCallbacks) {
const cb = getCallbackByOutput(graphs, paths, id, property);
if (cb) {
// callbacks found in the layout by output should always run
// unless specifically requested not to.
// ie this is the initial call of this callback even if it's
// not the page initialization but just a new layout chunk
if (!cb.callback.prevent_initial_call) {
if (!cb.callback.prevent_initial_call && !isCarryOver) {
cb.initialCall = true;
addCallback(cb);
}
Expand Down
Loading
Loading