Skip to content

Commit 3151c23

Browse files
Fix initial callbacks rerunning on existing elements during Patch() ops
1 parent 91e05f4 commit 3151c23

File tree

4 files changed

+102
-4
lines changed

4 files changed

+102
-4
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ This project adheres to [Semantic Versioning](https://semver.org/).
99

1010
## Fixed
1111
- [#3629](https://github.com/plotly/dash/pull/3629) Fix date pickers not showing date when initially rendered in a hidden container.
12-
- [#3627][(](https://github.com/plotly/dash/pull/3627)) Make dropdowns searchable wheen focused, without requiring to open them first
12+
- [#3627][(](https://github.com/plotly/dash/pull/3627)) Make dropdowns searchable when focused, without requiring to open them first
1313
- [#3656][(](https://github.com/plotly/dash/pull/3656)) Improved dropdown performance for large collections of options
1414
- [#3660][(](https://github.com/plotly/dash/pull/3660)) Allow same date to be selected for both start and end in DatePickerRange components
15+
- [#3682](https://github.com/plotly/dash/pull/3682) Fix initial callbacks when created via dcc.Patch
1516

1617

1718

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,7 +1194,8 @@ export function getWatchedKeys(id, newProps, graphs) {
11941194
* See getCallbackByOutput for details.
11951195
*/
11961196
export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) {
1197-
const {outputsOnly, removedArrayInputsOnly, newPaths, chunkPath} = opts;
1197+
const {outputsOnly, removedArrayInputsOnly, newPaths, chunkPath, oldPaths} =
1198+
opts;
11981199
const foundCbIds = {};
11991200
const callbacks = [];
12001201

@@ -1249,7 +1250,12 @@ export function getUnfilteredLayoutCallbacks(graphs, paths, layoutChunk, opts) {
12491250
// unless specifically requested not to.
12501251
// ie this is the initial call of this callback even if it's
12511252
// not the page initialization but just a new layout chunk
1252-
if (!cb.callback.prevent_initial_call) {
1253+
// Don't fire initial call for components that already existed before
1254+
// this chunk update (e.g. existing MATCH items when Patch adds a new one).
1255+
if (
1256+
!cb.callback.prevent_initial_call &&
1257+
!(chunkPath && oldPaths && getPath(oldPaths, id))
1258+
) {
12531259
cb.initialCall = true;
12541260
addCallback(cb);
12551261
}

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)