Skip to content

Commit 19095e9

Browse files
committed
callback.prevent_initial_call
1 parent 62806f3 commit 19095e9

File tree

3 files changed

+288
-10
lines changed

3 files changed

+288
-10
lines changed

dash-renderer/src/actions/dependencies.js

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,6 +1238,9 @@ export function getCallbacksInLayout(graphs, paths, layoutChunk, opts) {
12381238
) {
12391239
// This callback should trigger even with no changedProps,
12401240
// since the props that changed no longer exist.
1241+
// We're kind of abusing the `initialCall` flag here, it's
1242+
// more like a "final call" for the removed inputs, but
1243+
// this case is not subject to `prevent_initial_call`.
12411244
if (flatten(cb.getOutputs(newPaths)).length) {
12421245
cb.initialCall = true;
12431246
cb.changedPropIds = {};
@@ -1255,10 +1258,13 @@ export function getCallbacksInLayout(graphs, paths, layoutChunk, opts) {
12551258
const cb = getCallbackByOutput(graphs, paths, id, property);
12561259
if (cb) {
12571260
// callbacks found in the layout by output should always run
1261+
// unless specifically requested not to.
12581262
// ie this is the initial call of this callback even if it's
12591263
// not the page initialization but just a new layout chunk
1260-
cb.initialCall = true;
1261-
addCallback(cb);
1264+
if (!cb.callback.prevent_initial_call) {
1265+
cb.initialCall = true;
1266+
addCallback(cb);
1267+
}
12621268
}
12631269
}
12641270
}
@@ -1270,14 +1276,13 @@ export function getCallbacksInLayout(graphs, paths, layoutChunk, opts) {
12701276
if (chunkPath) {
12711277
handleThisCallback = cb => {
12721278
if (
1273-
all(
1279+
!all(
12741280
startsWith(chunkPath),
12751281
pluck('path', flatten(cb.getOutputs(paths)))
12761282
)
12771283
) {
1278-
cb.changedPropIds = {};
1284+
maybeAddCallback(cb);
12791285
}
1280-
maybeAddCallback(cb);
12811286
};
12821287
}
12831288
for (const property in inIdCallbacks) {

dash/dash.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -813,14 +813,15 @@ def interpolate_index(self, **kwargs):
813813
def dependencies(self):
814814
return flask.jsonify(self._callback_list)
815815

816-
def _insert_callback(self, output, inputs, state):
816+
def _insert_callback(self, output, inputs, state, prevent_initial_call):
817817
_validate.validate_callback(output, inputs, state)
818818
callback_id = create_callback_id(output)
819819
callback_spec = {
820820
"output": callback_id,
821821
"inputs": [c.to_dict() for c in inputs],
822822
"state": [c.to_dict() for c in state],
823823
"clientside_function": None,
824+
"prevent_initial_call": prevent_initial_call,
824825
}
825826
self.callback_map[callback_id] = {
826827
"inputs": callback_spec["inputs"],
@@ -830,7 +831,9 @@ def _insert_callback(self, output, inputs, state):
830831

831832
return callback_id
832833

833-
def clientside_callback(self, clientside_function, output, inputs, state=()):
834+
def clientside_callback(
835+
self, clientside_function, output, inputs, state=(), prevent_initial_call=False
836+
):
834837
"""Create a callback that updates the output by calling a clientside
835838
(JavaScript) function instead of a Python function.
836839
@@ -891,7 +894,7 @@ def clientside_callback(self, clientside_function, output, inputs, state=()):
891894
)
892895
```
893896
"""
894-
self._insert_callback(output, inputs, state)
897+
self._insert_callback(output, inputs, state, prevent_initial_call)
895898

896899
# If JS source is explicitly given, create a namespace and function
897900
# name, then inject the code.
@@ -922,8 +925,8 @@ def clientside_callback(self, clientside_function, output, inputs, state=()):
922925
"function_name": function_name,
923926
}
924927

925-
def callback(self, output, inputs, state=()):
926-
callback_id = self._insert_callback(output, inputs, state)
928+
def callback(self, output, inputs, state=(), prevent_initial_call=False):
929+
callback_id = self._insert_callback(output, inputs, state, prevent_initial_call)
927930
multi = isinstance(output, (list, tuple))
928931

929932
def wrap_func(func):
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import json
2+
import pytest
3+
4+
import dash_html_components as html
5+
import dash
6+
from dash.dependencies import Input, Output, MATCH
7+
from dash.exceptions import PreventUpdate
8+
9+
10+
def content_callback(app, content, layout):
11+
if content:
12+
app.layout = html.Div(id="content")
13+
14+
@app.callback(Output("content", "children"), [Input("content", "style")])
15+
def set_content(_):
16+
return layout
17+
18+
else:
19+
app.layout = layout
20+
21+
22+
def const_callback(app, clientside, val, outputs, inputs, prevent_initial_call=None):
23+
if clientside:
24+
vstr = json.dumps(val)
25+
app.clientside_callback(
26+
"function() { return " + vstr + "; }",
27+
outputs,
28+
inputs,
29+
prevent_initial_call=prevent_initial_call,
30+
)
31+
else:
32+
33+
@app.callback(outputs, inputs, prevent_initial_call=prevent_initial_call)
34+
def f(*args):
35+
return val
36+
37+
38+
def concat_callback(app, clientside, outputs, inputs, prevent_initial_call=None):
39+
multi_out = isinstance(outputs, (list, tuple))
40+
if clientside:
41+
app.clientside_callback(
42+
"""
43+
function() {
44+
var out = '';
45+
for(var i = 0; i < arguments.length; i++) {
46+
out += String(arguments[i]);
47+
}
48+
return X;
49+
}
50+
""".replace(
51+
"X",
52+
("[" + ','.join(["out"] * len(outputs)) + "]") if multi_out else "out"
53+
),
54+
outputs,
55+
inputs,
56+
prevent_initial_call=prevent_initial_call,
57+
)
58+
else:
59+
60+
@app.callback(outputs, inputs, prevent_initial_call=prevent_initial_call)
61+
def f(*args):
62+
out = "".join(str(arg) for arg in args)
63+
return [out] * len(outputs) if multi_out else out
64+
65+
66+
@pytest.mark.parametrize("clientside", (False, True))
67+
@pytest.mark.parametrize("content", (False, True))
68+
def test_cbpi001_prevent_initial_call(clientside, content, dash_duo):
69+
app = dash.Dash(__name__)
70+
layout = html.Div(
71+
[
72+
html.Button("click", id="btn"),
73+
html.Div("A", id="a"),
74+
html.Div("B", id="b"),
75+
html.Div("C", id="c"),
76+
html.Div("D", id="d"),
77+
html.Div("E", id="e"),
78+
html.Div("F", id="f"),
79+
]
80+
)
81+
content_callback(app, content, layout)
82+
83+
# Prevented, so A will only change after the button is clicked
84+
const_callback(
85+
app,
86+
clientside,
87+
"Click",
88+
Output("a", "children"),
89+
[Input("btn", "n_clicks")],
90+
prevent_initial_call=True,
91+
)
92+
93+
# B depends on A - this *will* run, because prevent_initial_call is
94+
# not equivalent to PreventUpdate within the callback, it's treated as if
95+
# that callback was never in the initialization chain.
96+
concat_callback(app, clientside, Output("b", "children"), [Input("a", "children")])
97+
98+
@app.callback(Output("d", "children"), [Input("d", "style")])
99+
def d(_):
100+
raise PreventUpdate
101+
102+
# E depends on A and D - one prevent_initial_call and one PreventUpdate
103+
# the prevent_initial_call means it *will* run
104+
concat_callback(
105+
app,
106+
clientside,
107+
Output("e", "children"),
108+
[Input("a", "children"), Input("d", "children")],
109+
)
110+
111+
# F has prevent_initial_call but DOES fire during init, because one of its
112+
# inputs (B) was changed by a callback
113+
concat_callback(
114+
app,
115+
clientside,
116+
Output("f", "children"),
117+
[Input("a", "children"), Input("b", "children"), Input("d", "children")],
118+
prevent_initial_call=True,
119+
)
120+
121+
# C matches B except that it also has prevent_initial_call itself, not just
122+
# its input A - so it will not run initially
123+
concat_callback(app, clientside, Output("c", "children"), [Input("a", "children")], prevent_initial_call=True)
124+
125+
dash_duo.start_server(app)
126+
127+
# check from the end, to ensure the callbacks are all done
128+
dash_duo.wait_for_text_to_equal("#f", "AAD")
129+
dash_duo.wait_for_text_to_equal("#e", "AD"),
130+
dash_duo.wait_for_text_to_equal("#d", "D")
131+
dash_duo.wait_for_text_to_equal("#c", "C")
132+
dash_duo.wait_for_text_to_equal("#b", "A")
133+
dash_duo.wait_for_text_to_equal("#a", "A")
134+
135+
dash_duo.find_element("#btn").click()
136+
137+
dash_duo.wait_for_text_to_equal("#f", "ClickClickD")
138+
dash_duo.wait_for_text_to_equal("#e", "ClickD"),
139+
dash_duo.wait_for_text_to_equal("#d", "D")
140+
dash_duo.wait_for_text_to_equal("#c", "Click")
141+
dash_duo.wait_for_text_to_equal("#b", "Click")
142+
dash_duo.wait_for_text_to_equal("#a", "Click")
143+
144+
145+
@pytest.mark.parametrize("clientside", (False, True))
146+
@pytest.mark.parametrize("content", (False, True))
147+
def test_cbpi002_pattern_matching(clientside, content, dash_duo):
148+
# a clone of cbpi001 just throwing it through the pattern-matching machinery
149+
app = dash.Dash(__name__)
150+
layout = html.Div(
151+
[
152+
html.Button("click", id={"i": 0, "j": "btn"}, className="btn"),
153+
html.Div("A", id={"i": 0, "j": "a"}, className="a"),
154+
html.Div("B", id={"i": 0, "j": "b"}, className="b"),
155+
html.Div("C", id={"i": 0, "j": "c"}, className="c"),
156+
html.Div("D", id={"i": 0, "j": "d"}, className="d"),
157+
html.Div("E", id={"i": 0, "j": "e"}, className="e"),
158+
html.Div("F", id={"i": 0, "j": "f"}, className="f"),
159+
]
160+
)
161+
content_callback(app, content, layout)
162+
163+
# Prevented, so A will only change after the button is clicked
164+
const_callback(
165+
app,
166+
clientside,
167+
"Click",
168+
Output({"i": MATCH, "j": "a"}, "children"),
169+
[Input({"i": MATCH, "j": "btn"}, "n_clicks")],
170+
prevent_initial_call=True,
171+
)
172+
173+
# B depends on A - this *will* run, because prevent_initial_call is
174+
# not equivalent to PreventUpdate within the callback, it's treated as if
175+
# that callback was never in the initialization chain.
176+
concat_callback(app, clientside, Output({"i": MATCH, "j": "b"}, "children"), [Input({"i": MATCH, "j": "a"}, "children")])
177+
178+
@app.callback(Output({"i": MATCH, "j": "d"}, "children"), [Input({"i": MATCH, "j": "d"}, "style")])
179+
def d(_):
180+
raise PreventUpdate
181+
182+
# E depends on A and D - one prevent_initial_call and one PreventUpdate
183+
# the prevent_initial_call means it *will* run
184+
concat_callback(
185+
app,
186+
clientside,
187+
Output({"i": MATCH, "j": "e"}, "children"),
188+
[Input({"i": MATCH, "j": "a"}, "children"), Input({"i": MATCH, "j": "d"}, "children")],
189+
)
190+
191+
# F has prevent_initial_call but DOES fire during init, because one of its
192+
# inputs (B) was changed by a callback
193+
concat_callback(
194+
app,
195+
clientside,
196+
Output({"i": MATCH, "j": "f"}, "children"),
197+
[Input({"i": MATCH, "j": "a"}, "children"), Input({"i": MATCH, "j": "b"}, "children"), Input({"i": MATCH, "j": "d"}, "children")],
198+
prevent_initial_call=True,
199+
)
200+
201+
# C matches B except that it also has prevent_initial_call itself, not just
202+
# its input A - so it will not run initially
203+
concat_callback(app, clientside, Output({"i": MATCH, "j": "c"}, "children"), [Input({"i": MATCH, "j": "a"}, "children")], prevent_initial_call=True)
204+
205+
dash_duo.start_server(app)
206+
207+
# check from the end, to ensure the callbacks are all done
208+
dash_duo.wait_for_text_to_equal(".f", "AAD")
209+
dash_duo.wait_for_text_to_equal(".e", "AD"),
210+
dash_duo.wait_for_text_to_equal(".d", "D")
211+
dash_duo.wait_for_text_to_equal(".c", "C")
212+
dash_duo.wait_for_text_to_equal(".b", "A")
213+
dash_duo.wait_for_text_to_equal(".a", "A")
214+
215+
dash_duo.find_element(".btn").click()
216+
217+
dash_duo.wait_for_text_to_equal(".f", "ClickClickD")
218+
dash_duo.wait_for_text_to_equal(".e", "ClickD"),
219+
dash_duo.wait_for_text_to_equal(".d", "D")
220+
dash_duo.wait_for_text_to_equal(".c", "Click")
221+
dash_duo.wait_for_text_to_equal(".b", "Click")
222+
dash_duo.wait_for_text_to_equal(".a", "Click")
223+
224+
225+
@pytest.mark.parametrize("clientside", (False, True))
226+
@pytest.mark.parametrize("content", (False, True))
227+
def test_cbpi003_multi_outputs(clientside, content, dash_duo):
228+
app = dash.Dash(__name__)
229+
230+
layout = html.Div([
231+
html.Button("click", id="btn"),
232+
html.Div("A", id="a"),
233+
html.Div("B", id="b"),
234+
html.Div("C", id="c"),
235+
html.Div("D", id="d"),
236+
html.Div("E", id="e"),
237+
html.Div("F", id="f"),
238+
html.Div("G", id="g"),
239+
])
240+
241+
content_callback(app, content, layout)
242+
243+
const_callback(app, clientside, ["Blue", "Cheese"], [Output("a", "children"), Output("b", "children")], [Input("btn", "n_clicks")], prevent_initial_call=True)
244+
245+
concat_callback(app, clientside, [Output("c", "children"), Output("d", "children")], [Input("a", "children"), Input("b", "children")], prevent_initial_call=True)
246+
247+
concat_callback(app, clientside, [Output("e", "children"), Output("f", "children")], [Input("a", "children")], prevent_initial_call=True)
248+
249+
# this is the only one that should run initially
250+
concat_callback(app, clientside, Output("g", "children"), [Input("f", "children")])
251+
252+
dash_duo.start_server(app)
253+
254+
dash_duo.wait_for_text_to_equal("#g", "F")
255+
dash_duo.wait_for_text_to_equal("#f", "F")
256+
dash_duo.wait_for_text_to_equal("#e", "E")
257+
dash_duo.wait_for_text_to_equal("#d", "D")
258+
dash_duo.wait_for_text_to_equal("#c", "C")
259+
dash_duo.wait_for_text_to_equal("#b", "B")
260+
dash_duo.wait_for_text_to_equal("#a", "A")
261+
262+
dash_duo.find_element("#btn").click()
263+
264+
dash_duo.wait_for_text_to_equal("#g", "Blue")
265+
dash_duo.wait_for_text_to_equal("#f", "Blue")
266+
dash_duo.wait_for_text_to_equal("#e", "Blue")
267+
dash_duo.wait_for_text_to_equal("#d", "BlueCheese")
268+
dash_duo.wait_for_text_to_equal("#c", "BlueCheese")
269+
dash_duo.wait_for_text_to_equal("#b", "Cheese")
270+
dash_duo.wait_for_text_to_equal("#a", "Blue")

0 commit comments

Comments
 (0)