Skip to content

Commit 67f74e2

Browse files
Fix initial callbacks rerunning on existing elements during Patch() ops
1 parent cd8648b commit 67f74e2

File tree

4 files changed

+101
-4
lines changed

4 files changed

+101
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
66

77
## Fixed
88
- [#3690](https://github.com/plotly/dash/pull/3690) Fixes Input when min or max is set to None
9-
9+
- [#3682](https://github.com/plotly/dash/pull/3682) Fix initial callbacks when created via dcc.Patch
1010
## [4.1.0] - 2026-03-23
1111

1212
## Added

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,7 +1243,8 @@ export function getWatchedKeys(id, newProps, graphs) {
12431243
* See getCallbackByOutput for details.
12441244
*/
12451245
export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) {
1246-
const {outputsOnly, removedArrayInputsOnly, newPaths, chunkPath} = opts;
1246+
const {outputsOnly, removedArrayInputsOnly, newPaths, chunkPath, oldPaths} =
1247+
opts;
12471248
const foundCbIds = {};
12481249
const callbacks = [];
12491250

@@ -1298,7 +1299,12 @@ export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) {
12981299
// unless specifically requested not to.
12991300
// ie this is the initial call of this callback even if it's
13001301
// not the page initialization but just a new layout chunk
1301-
if (!cb.callback.prevent_initial_call) {
1302+
// Don't fire initial call for components that already existed before
1303+
// this chunk update (e.g. existing MATCH items when Patch adds a new one).
1304+
if (
1305+
!cb.callback.prevent_initial_call &&
1306+
!(chunkPath && oldPaths && getPath(oldPaths, id))
1307+
) {
13021308
cb.initialCall = true;
13031309
addCallback(cb);
13041310
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ const observer: IStoreObserverDefinition<IStoreState> = {
160160
requestedCallbacks,
161161
getLayoutCallbacks(graphs, paths, children, {
162162
chunkPath: oldChildrenPath,
163+
oldPaths: oPaths,
163164
filterRoot
164165
}).map(rcb => ({
165166
...rcb,

tests/integration/callbacks/test_wildcards.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import pytest
22
import re
3+
import threading
34
from selenium.webdriver.common.keys import Keys
45
import json
56
from multiprocessing import Lock
67

78
from dash.testing import wait
89
import dash
9-
from dash import Dash, Input, Output, State, ALL, ALLSMALLER, MATCH, html, dcc
10+
from dash import Dash, Input, Output, State, ALL, ALLSMALLER, MATCH, html, dcc, Patch
1011

1112
from tests.assets.todo_app import todo_app
1213
from tests.assets.grouping_app import grouping_app
@@ -619,3 +620,92 @@ def on_click(_) -> str:
619620
assert not dash_duo.find_element("#buttons button:nth-child(2)").get_attribute(
620621
"disabled"
621622
)
623+
624+
625+
def test_cbwc009_patch_no_spurious_match_callbacks(dash_duo):
626+
"""Regression test for the oldPaths fix in getUnfilteredLayoutCallbacks.
627+
628+
When Patch() appends a new MATCH-pattern component, existing MATCH callbacks
629+
must NOT re-fire for pre-existing components. Previously, crawlLayout would
630+
visit all children in the layout chunk and mark every matching output as
631+
initialCall=true, causing all existing callbacks to spuriously re-execute.
632+
633+
The fix passes oldPaths (the pre-update paths snapshot) into
634+
getUnfilteredLayoutCallbacks and skips initialCall for any component whose
635+
ID already exists in oldPaths.
636+
"""
637+
lock = threading.Lock()
638+
fire_counts = {} # {index: count} how many times each MATCH callback fired
639+
640+
def make_item(index):
641+
return html.Div(
642+
[
643+
dcc.Input(
644+
id={"type": "item-input", "index": index},
645+
value=index,
646+
type="number",
647+
className="item-input",
648+
),
649+
html.Div(
650+
"init",
651+
id={"type": "item-output", "index": index},
652+
className="item-output",
653+
),
654+
]
655+
)
656+
657+
app = Dash(__name__)
658+
app.layout = html.Div(
659+
[
660+
html.Button("Add", id="add-btn", n_clicks=0),
661+
html.Div([make_item(0), make_item(1)], id="container"),
662+
]
663+
)
664+
665+
@app.callback(
666+
Output("container", "children"),
667+
Input("add-btn", "n_clicks"),
668+
prevent_initial_call=True,
669+
)
670+
def add_item(n):
671+
p = Patch()
672+
p.append(make_item(n + 1))
673+
return p
674+
675+
@app.callback(
676+
Output({"type": "item-output", "index": MATCH}, "children"),
677+
Input({"type": "item-input", "index": MATCH}, "value"),
678+
)
679+
def on_value_change(value):
680+
from dash import ctx
681+
682+
idx = ctx.outputs_grouping["id"]["index"]
683+
with lock:
684+
fire_counts[idx] = fire_counts.get(idx, 0) + 1
685+
count = fire_counts[idx]
686+
return f"fired-{idx}-#{count}"
687+
688+
dash_duo.start_server(app)
689+
690+
# Wait for the initial callbacks to fire for both pre-existing items.
691+
wait.until(lambda: fire_counts.get(0, 0) >= 1, 5)
692+
wait.until(lambda: fire_counts.get(1, 0) >= 1, 5)
693+
694+
counts_before = {0: fire_counts[0], 1: fire_counts[1]}
695+
696+
# Add a new item via Patch, this should fire only for index 2.
697+
dash_duo.find_element("#add-btn").click()
698+
wait.until(lambda: fire_counts.get(2, 0) >= 1, 5)
699+
700+
# Pre-existing callbacks must NOT have re-fired.
701+
assert fire_counts[0] == counts_before[0], (
702+
f"Item 0 callback fired spuriously after Patch: "
703+
f"was {counts_before[0]}, now {fire_counts[0]}"
704+
)
705+
assert fire_counts[1] == counts_before[1], (
706+
f"Item 1 callback fired spuriously after Patch: "
707+
f"was {counts_before[1]}, now {fire_counts[1]}"
708+
)
709+
assert (
710+
fire_counts[2] == 1
711+
), f"New item 2 callback should have fired exactly once, fired {fire_counts[2]}"

0 commit comments

Comments
 (0)