Skip to content

Commit a9136db

Browse files
Fix initial callbacks suppression on non-Patch full replacements
1 parent 90fc7c6 commit a9136db

File tree

5 files changed

+106
-7
lines changed

5 files changed

+106
-7
lines changed

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {createAction, Action} from 'redux-actions';
4141
import {addHttpHeaders} from '../actions';
4242
import {notifyObservers, updateProps} from './index';
4343
import {CallbackJobPayload} from '../reducers/callbackJobs';
44-
import {parsePatchProps} from './patch';
44+
import {isPatch, parsePatchProps} from './patch';
4545
import {computePaths, getPath} from './paths';
4646

4747
import {requestDependencies} from './requestDependencies';
@@ -826,6 +826,7 @@ export function executeCallback(
826826
);
827827
// Patch methodology: always run through parsePatchProps for each output
828828
const currentLayout = getState().layout;
829+
let wasPatch = false;
829830
flatten(outputs).forEach((out: any) => {
830831
const propName = cleanOutputProp(out.property);
831832
const outputPath = getPath(paths, out.id);
@@ -834,6 +835,9 @@ export function executeCallback(
834835
if (outputValue === undefined) {
835836
return;
836837
}
838+
if (isPatch(outputValue)) {
839+
wasPatch = true;
840+
}
837841
const oldProps =
838842
path(
839843
outputPath.concat(['props']),
@@ -849,7 +853,7 @@ export function executeCallback(
849853
data
850854
);
851855
});
852-
return {data, payload};
856+
return {data, payload, ...(wasPatch ? {prePatchPaths: paths} : {})};
853857
} catch (error: any) {
854858
return {error, payload};
855859
}
@@ -909,6 +913,7 @@ export function executeCallback(
909913
// Layout may have changed.
910914
// DRY: Always run through parsePatchProps for each output
911915
const currentLayout = getState().layout;
916+
let wasPatch = false;
912917
flatten(outputs).forEach((out: any) => {
913918
const propName = cleanOutputProp(out.property);
914919
const outputPath = getPath(paths, out.id);
@@ -917,6 +922,9 @@ export function executeCallback(
917922
if (outputValue === undefined) {
918923
return;
919924
}
925+
if (isPatch(outputValue)) {
926+
wasPatch = true;
927+
}
920928
const oldProps =
921929
path(
922930
outputPath.concat(['props']),
@@ -941,7 +949,7 @@ export function executeCallback(
941949
);
942950
}
943951

944-
return {data, payload};
952+
return {data, payload, ...(wasPatch ? {prePatchPaths: paths} : {})};
945953
} catch (res: any) {
946954
lastError = res;
947955
if (

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1311,7 +1311,7 @@ export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) {
13111311
// props that were undefined in both old and new layouts (undefined === undefined).
13121312
if (oldPropValue === undefined) return false;
13131313
const newPropValue = child ? path(['props', property], child) : undefined;
1314-
return oldPropValue === newPropValue;
1314+
return equals(oldPropValue, newPropValue);
13151315
}
13161316

13171317
function handleOneId(id, outIdCallbacks, inIdCallbacks, child) {

dash/dash-renderer/src/observers/executedCallbacks.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const observer: IStoreObserverDefinition<IStoreState> = {
9393
return;
9494
}
9595

96-
const {data, error, payload} = executionResult;
96+
const {data, error, payload, prePatchPaths} = executionResult;
9797

9898
if (data !== undefined) {
9999
Object.entries(data).forEach(
@@ -156,12 +156,21 @@ const observer: IStoreObserverDefinition<IStoreState> = {
156156
dispatch(setPaths(paths));
157157

158158
// Get callbacks for new layout (w/ execution group)
159+
// Only pass oldPaths/oldLayout for Patch callbacks:
160+
// isUnchangedOutputProp must only suppress initial
161+
// calls when a Patch carried over existing components.
162+
// For full-replacement callbacks every component is a
163+
// fresh instance and all initial calls must fire.
159164
requestedCallbacks = concat(
160165
requestedCallbacks,
161166
getLayoutCallbacks(graphs, paths, children, {
162167
chunkPath: oldChildrenPath,
163-
oldPaths: oPaths,
164-
oldLayout: oldLayout,
168+
...(prePatchPaths
169+
? {
170+
oldPaths: oPaths,
171+
oldLayout: oldLayout
172+
}
173+
: {}),
165174
filterRoot
166175
}).map(rcb => ({
167176
...rcb,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export type CallbackResult = {
8383
data?: CallbackResponse;
8484
error?: Error;
8585
payload: ICallbackPayload | null;
86+
prePatchPaths?: any;
8687
};
8788

8889
export type BackgroundCallbackInfo = {

tests/integration/callbacks/test_wildcards.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,3 +709,84 @@ def on_value_change(value):
709709
assert (
710710
fire_counts[2] == 1
711711
), f"New item 2 callback should have fired exactly once, fired {fire_counts[2]}"
712+
713+
714+
def test_cbwc010_full_replace_fires_initial_callbacks(dash_duo):
715+
"""Regression test ensuring full-replacement (non-Patch) outputs still fire
716+
initial callbacks for all replaced components.
717+
718+
When a callback returns a full list (not a Patch), every component in the
719+
new layout is a fresh instance. The isUnchangedOutputProp suppression must
720+
NOT apply here: prePatchPaths is absent from the execution result, so
721+
oldPaths/oldLayout are never passed, and all initial calls must fire.
722+
"""
723+
lock = threading.Lock()
724+
fire_counts = {} # {index: count}
725+
726+
def make_item(index):
727+
return html.Div(
728+
[
729+
dcc.Input(
730+
id={"type": "fr-input", "index": index},
731+
value=index,
732+
type="number",
733+
className="fr-input",
734+
),
735+
html.Div(
736+
"init",
737+
id={"type": "fr-output", "index": index},
738+
className="fr-output",
739+
),
740+
]
741+
)
742+
743+
app = Dash(__name__)
744+
app.layout = html.Div(
745+
[
746+
html.Button("Replace", id="replace-btn", n_clicks=0),
747+
html.Div([make_item(0), make_item(1)], id="fr-container"),
748+
]
749+
)
750+
751+
@app.callback(
752+
Output("fr-container", "children"),
753+
Input("replace-btn", "n_clicks"),
754+
prevent_initial_call=True,
755+
)
756+
def replace_items(_):
757+
# Full replacement — returns a plain list, not a Patch
758+
return [make_item(10), make_item(11)]
759+
760+
@app.callback(
761+
Output({"type": "fr-output", "index": MATCH}, "children"),
762+
Input({"type": "fr-input", "index": MATCH}, "value"),
763+
)
764+
def on_value_change(value):
765+
from dash import ctx
766+
767+
idx = ctx.outputs_grouping["id"]["index"]
768+
with lock:
769+
fire_counts[idx] = fire_counts.get(idx, 0) + 1
770+
count = fire_counts[idx]
771+
return f"fired-{idx}-#{count}"
772+
773+
dash_duo.start_server(app)
774+
775+
# Wait for the initial callbacks for items 0 and 1.
776+
wait.until(lambda: fire_counts.get(0, 0) >= 1, 5)
777+
wait.until(lambda: fire_counts.get(1, 0) >= 1, 5)
778+
779+
# Trigger a full replacement.
780+
dash_duo.find_element("#replace-btn").click()
781+
782+
# After full replacement, items 10 and 11 are brand-new instances and
783+
# MUST have their initial callbacks fire.
784+
wait.until(lambda: fire_counts.get(10, 0) >= 1, 5)
785+
wait.until(lambda: fire_counts.get(11, 0) >= 1, 5)
786+
787+
assert (
788+
fire_counts[10] >= 1
789+
), f"New item 10 callback should have fired after full replace, got {fire_counts.get(10, 0)}"
790+
assert (
791+
fire_counts[11] >= 1
792+
), f"New item 11 callback should have fired after full replace, got {fire_counts.get(11, 0)}"

0 commit comments

Comments
 (0)