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 @@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
### Fixed
- [#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.
- [#???](https://github.com/plotly/dash/pull/???) 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
48 changes: 41 additions & 7 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,10 @@ function cleanOutputProp(property: string) {
return property.split('@')[0];
}

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

async function handleClientside(
dispatch: any,
clientside_function: any,
Expand Down Expand Up @@ -970,30 +976,44 @@ export function executeCallback(
);
// Patch methodology: always run through parsePatchProps for each output
const currentLayout = getState().layout;
const patchedOutputs: PatchedOutputs = {};
flatten(outputs).forEach((out: any) => {
const propName = cleanOutputProp(out.property);
const outputPath = getPath(paths, out.id);
const dataPath = [stringifyId(out.id), propName];
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
oldProps,
patchedOutputs[idStr]
);
data = assocPath(
dataPath,
newProps[propName],
data
);
});
return {data, payload};
return {
data,
payload,
...patchedResultFields(patchedOutputs)
};
} catch (error: any) {
return {error, payload};
}
Expand Down Expand Up @@ -1079,22 +1099,32 @@ export function executeCallback(
// Layout may have changed.
// DRY: Always run through parsePatchProps for each output
const currentLayout = getState().layout;
const patchedOutputs: PatchedOutputs = {};
flatten(outputs).forEach((out: any) => {
const propName = cleanOutputProp(out.property);
const outputPath = getPath(paths, out.id);
const dataPath = [stringifyId(out.id), propName];
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
oldProps,
patchedOutputs[idStr]
);

data = assocPath(
Expand All @@ -1111,7 +1141,11 @@ export function executeCallback(
);
}

return {data, payload};
return {
data,
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
123 changes: 120 additions & 3 deletions dash/dash-renderer/src/actions/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
reverse
} from 'ramda';

import {stringifyId} from './dependencies';
import {isDryComponent} from '../wrapper/wrapping';
import {PatchAnalysis} from './patchAnalysis';

type PatchOperation = {
operation: string;
location: LocationIndex[];
Expand Down Expand Up @@ -288,7 +292,108 @@ const patchHandlers: {[k: string]: PatchHandler} = {
}
};

export function handlePatch<T>(previousValue: T, patchValue: any): T {
/*
* Operations that put a value into the tree, which add/remove ids,
* and need to be handled during a patch
*/
const insertingOperations: {[operation: string]: true} = {
Assign: true,
Merge: true,
Extend: true,
Insert: true,
Append: true,
Prepend: true
};

function collectComponentIds(
value: any,
freshIds: PatchAnalysis['freshIds'],
visited: Set<any>
) {
if (!value || typeof value !== 'object' || visited.has(value)) {
return;
}
visited.add(value);

if (Array.isArray(value)) {
value.forEach(item => collectComponentIds(item, freshIds, visited));
return;
}

if (isDryComponent(value)) {
const {id} = value.props;
if (id !== undefined && id !== null) {
freshIds[stringifyId(id)] = true;
}
collectComponentIds(value.props, freshIds, visited);
return;
}

for (const key in value) {
collectComponentIds(value[key], freshIds, visited);
}
}

function recordWrittenProp(
previous: any,
location: LocationIndex[],
writtenProps: PatchAnalysis['writtenProps']
) {
let current = previous;
let idStr: string | null = null;
let property: string | null = null;

for (let i = 0; i < location.length && current; i++) {
const key = location[i];
if (
key === 'props' &&
i + 1 < location.length &&
isDryComponent(current) &&
current.props.id !== undefined &&
current.props.id !== null
) {
idStr = stringifyId(current.props.id);
property = String(location[i + 1]);
}
current = current[key];
}

if (idStr !== null && property !== null) {
const props = writtenProps[idStr] || (writtenProps[idStr] = {});
props[property] = true;
}
}

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

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

if (operation === 'Merge' && params.value && is(Object, params.value)) {
Object.keys(params.value).forEach(key =>
recordWrittenProp(
previous,
location.concat(key),
analysis.writtenProps
)
);
return;
}

recordWrittenProp(previous, location, analysis.writtenProps);
}

export function handlePatch<T>(
previousValue: T,
patchValue: any,
analysis?: PatchAnalysis
): T {
let reducedValue = previousValue;

for (let i = 0; i < patchValue.operations.length; i++) {
Expand All @@ -298,15 +403,24 @@ export function handlePatch<T>(previousValue: T, patchValue: any): T {
if (!handler) {
throw new Error(`Invalid Operation ${patch.operation}`);
}
if (analysis) {
recordPatchOperation(reducedValue, patch, analysis);
}
reducedValue = handler(reducedValue, patch);
}

return reducedValue;
}

/*
* `analysis`, when provided, is filled in with what the patches did.
* Props that are not patches are left out of it, so callers
* can tell a patched prop from a fully replaced one
*/
export function parsePatchProps(
props: any,
previousProps: any
previousProps: any,
analysis?: PatchAnalysis
): Record<string, any> {
if (!is(Object, props)) {
return props;
Expand All @@ -321,7 +435,10 @@ export function parsePatchProps(
if (previousValue === undefined) {
throw new Error('Cannot patch undefined');
}
patchedProps[key] = handlePatch(previousValue, val);
if (analysis) {
analysis.patchedProps[key] = true;
}
patchedProps[key] = handlePatch(previousValue, val, analysis);
} else {
patchedProps[key] = val;
}
Expand Down
Loading
Loading