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 @@ -15,6 +15,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
71 changes: 55 additions & 16 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, wasWrittenByPatch} from './patchAnalysis';

import {crawlLayout} from './utils';

Expand Down Expand Up @@ -1262,13 +1263,26 @@ 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
* It also allows an input the patch wrote directly to bypass the chunkPath
* dedup, when that input's own component was carried over (so
* its own initial call stays suppressed) but downstream callbacks still
* need to see the new value
* 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,38 +1330,63 @@ 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);
}
}
}
}
if (!outputsOnly && inIdCallbacks) {
const idStr = stringifyId(id);
const maybeAddCallback = removedArrayInputsOnly
? addCallbackIfArray(stringifyId(id))
? addCallbackIfArray(idStr)
: addCallback;
let handleThisCallback = maybeAddCallback;
if (chunkPath) {
handleThisCallback = cb => {
if (
!all(
startsWith(chunkPath),
pluck('path', flatten(cb.getOutputs(paths)))
)
) {
maybeAddCallback(cb);
}
};
}
for (const property in inIdCallbacks) {
// A callback, whose outputs are all inside the chunk, is
// normally dropped here on the assumption that the
// output handling above already covers it
// That assumption fails when the patch
// wrote a new value directly on this input without
// recreating the input's own component
// The output side stays suppressed because the output
// component was carried over, but this input's value
// genuinely changed, so the callback must still be added
let handleThisCallback = maybeAddCallback;
if (
chunkPath &&
!wasWrittenByPatch(patchAnalysis, idStr, property)
) {
handleThisCallback = cb => {
if (
!all(
startsWith(chunkPath),
pluck('path', flatten(cb.getOutputs(paths)))
)
) {
maybeAddCallback(cb);
}
};
}
getCallbacksByInput(
graphs,
paths,
Expand Down
Loading
Loading