Skip to content

Commit 92d6121

Browse files
authored
Merge branch 'dev' into fix-pages-pathname-decoding
2 parents ae8048f + 40c6f7f commit 92d6121

7 files changed

Lines changed: 173 additions & 21 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
6060
- [#4057](https://github.com/plotly/dash/pull/4057) Fixed background callbacks to update `set_props` instead of replacing. Fixes [#3104](https://github.com/plotly/dash/issues/3104).
6161
- [#3923](https://github.com/plotly/dash/pull/3923) Fix a blank page and `SyntaxError` in the browser when a dict component ID has a dot in one of its values. Fixes [#3480](https://github.com/plotly/dash/issues/3480).
6262
- [#4074](https://github.com/plotly/dash/pull/4074) Fix Pages passing percent-encoded path variables to `layout` and `title`, and the 404 page for paths with spaces or non-ASCII characters. Fixes [#2360](https://github.com/plotly/dash/issues/2360).
63+
- [#4012](https://github.com/plotly/dash/pull/4012) Fix `InvalidCallbackReturnValue` raised when a no-output background callback's error handler returns `None` (or the job is cancelled), by treating `None` returns from error handlers and the cancelled-job branch the same way — as `NoUpdate()` only when there are outputs, `None` otherwise. Fixes [#3628](https://github.com/plotly/dash/issues/3628).
6364

6465
## [4.4.1] - 2026-07-21
6566

‎dash/_callback.py‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,7 @@ def _update_background_callback(
683683
kwargs,
684684
background,
685685
multi,
686+
output_spec,
686687
cache_key=None,
687688
job_id=None,
688689
):
@@ -711,6 +712,7 @@ def _update_background_callback(
711712
error_handler,
712713
callback_ctx,
713714
multi,
715+
output_spec,
714716
cache_key=cache_key,
715717
job_id=job_id,
716718
)
@@ -723,6 +725,7 @@ def _handle_rest_background_callback(
723725
error_handler,
724726
callback_ctx,
725727
multi,
728+
output_spec,
726729
has_update=False,
727730
cache_key=None,
728731
job_id=None,
@@ -731,7 +734,10 @@ def _handle_rest_background_callback(
731734
job_running = callback_manager.job_running(job_id) if job_id is not None else False
732735
if not job_running and output_value is callback_manager.UNDEFINED:
733736
# Job canceled -> no output to close the loop.
734-
output_value = NoUpdate()
737+
if output_spec:
738+
output_value = NoUpdate()
739+
else:
740+
output_value = None
735741

736742
elif isinstance(output_value, dict) and "background_callback_error" in output_value:
737743
error = output_value.get("background_callback_error", {})
@@ -741,7 +747,7 @@ def _handle_rest_background_callback(
741747
if error_handler:
742748
output_value = error_handler(exc)
743749

744-
if output_value is None:
750+
if output_value is None and output_spec:
745751
output_value = NoUpdate()
746752
# set_props from the error handler uses the original ctx
747753
# instead of manager.get_updated_props since it runs in the
@@ -975,7 +981,13 @@ def add_context(*args, **kwargs):
975981
)
976982

977983
output_value, has_update, skip = _update_background_callback(
978-
error_handler, callback_ctx, response, kwargs, background, multi
984+
error_handler,
985+
callback_ctx,
986+
response,
987+
kwargs,
988+
background,
989+
multi,
990+
output_spec,
979991
)
980992
if skip:
981993
return output_value
@@ -1047,7 +1059,13 @@ async def async_add_context(*args, **kwargs):
10471059
callback_ctx,
10481060
)
10491061
output_value, has_update, skip = _update_background_callback(
1050-
error_handler, callback_ctx, response, kwargs, background, multi
1062+
error_handler,
1063+
callback_ctx,
1064+
response,
1065+
kwargs,
1066+
background,
1067+
multi,
1068+
output_spec,
10511069
)
10521070
if skip:
10531071
return output_value

‎dash/mcp/tasks/tasks.py‎

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from dash._callback import _update_background_callback, _prepare_response
1313
from dash._utils import AttributeDict, split_callback_id
1414
from dash.development.base_component import ComponentRegistry
15+
from dash.exceptions import PreventUpdate
1516
from dash.mcp.primitives.tools.results import format_callback_response
1617
from dash.mcp.types import MCPError
1718
from dash.types import CallbackExecutionResponse
@@ -146,7 +147,7 @@ def get_task_result(task_id: str) -> Any:
146147
background = cb_info.get("background")
147148
has_output = not cb_info.get("no_output")
148149
multi = adapter.output_id.startswith("..")
149-
output = split_callback_id(adapter.output_id)
150+
output = split_callback_id(adapter.output_id) if has_output else []
150151

151152
# Build the body to get output_spec, same as as_callback_body
152153
body = adapter.as_callback_body({})
@@ -162,6 +163,7 @@ def get_task_result(task_id: str) -> Any:
162163
kwargs={"background_callback_manager": manager},
163164
background=background,
164165
multi=multi,
166+
output_spec=output_spec,
165167
cache_key=cache_key,
166168
job_id=job_id,
167169
)
@@ -172,21 +174,24 @@ def get_task_result(task_id: str) -> Any:
172174
"Task result not ready. Poll tasks/get until status is 'completed'."
173175
)
174176

175-
_prepare_response(
176-
output_value,
177-
output_spec,
178-
multi,
179-
response,
180-
callback_ctx,
181-
app,
182-
set(ComponentRegistry.registry),
183-
background,
184-
has_update,
185-
has_output,
186-
output,
187-
adapter.output_id,
188-
cb_info.get("allow_dynamic_callbacks"),
189-
)
177+
try:
178+
_prepare_response(
179+
output_value,
180+
output_spec,
181+
multi,
182+
response,
183+
callback_ctx,
184+
app,
185+
set(ComponentRegistry.registry),
186+
background,
187+
has_update,
188+
has_output,
189+
output,
190+
adapter.output_id,
191+
cb_info.get("allow_dynamic_callbacks"),
192+
)
193+
except PreventUpdate:
194+
return format_callback_response(response, adapter)
190195

191196
return format_callback_response(response, adapter)
192197

‎tests/background_callback/app_bg_on_error.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ def global_error_handler(err):
2020
html.Div(id="cb-output"),
2121
html.Button("global on_error", id="start-global-onerror"),
2222
html.Div(id="global-output"),
23+
html.Button("no-output callback on_error", id="start-no-output-cb-onerror"),
24+
html.Div(id="no-output-cb-onerror-output"),
2325
]
2426

2527

@@ -48,5 +50,19 @@ def on_click_global(_):
4850
raise Exception("global error")
4951

5052

53+
def no_output_callback_on_error(err):
54+
set_props("no-output-cb-onerror-output", {"children": f"callback: {err}"})
55+
56+
57+
@app.callback(
58+
Input("start-no-output-cb-onerror", "n_clicks"),
59+
prevent_initial_call=True,
60+
background=True,
61+
on_error=no_output_callback_on_error,
62+
)
63+
def on_click_no_output(_):
64+
raise Exception("no output callback error")
65+
66+
5167
if __name__ == "__main__":
5268
app.run(debug=True)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from tests.background_callback.utils import setup_background_callback_app
2+
3+
4+
def test_lcbc019_background_callback_on_error_without_output(dash_duo, manager):
5+
with setup_background_callback_app(manager, "app_bg_on_error") as app:
6+
dash_duo.start_server(app)
7+
8+
dash_duo.find_element("#start-no-output-cb-onerror").click()
9+
dash_duo.wait_for_contains_text(
10+
"#no-output-cb-onerror-output",
11+
"callback: An error occurred inside a background callback: no output callback error",
12+
)

‎tests/integration/mcp/test_mcp_background_tasks.py‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
from dash import Dash, Input, Output, html, _callback_signing
1818
from dash.background_callback.managers.diskcache_manager import DiskcacheManager
1919

20+
from tests.integration.mcp.conftest import _mcp_method
21+
2022
MCP_PATH = "_mcp"
2123

2224

@@ -89,6 +91,41 @@ def _post(client, method, params=None, request_id=1):
8991
# ---------------------------------------------------------------------------
9092

9193

94+
def test_mcpbg_cancel_no_output_task_result(dash_duo):
95+
manager = DiskcacheManager(diskcache.Cache())
96+
app = Dash(__name__)
97+
app.layout = html.Div(id="trigger")
98+
99+
@app.callback(
100+
Input("trigger", "children"),
101+
background=True,
102+
manager=manager,
103+
prevent_initial_call=True,
104+
)
105+
def no_output_callback(value):
106+
time.sleep(0.5)
107+
108+
dash_duo.start_server(app)
109+
created = _mcp_method(
110+
dash_duo.server_url,
111+
"tools/call",
112+
{
113+
"name": "no_output_callback",
114+
"arguments": {"value": "hello"},
115+
"task": {"ttl": 60000},
116+
},
117+
)
118+
task_id = created["result"]["task"]["taskId"]
119+
job_id, _ = _unwrap_handles(app, task_id)
120+
cancelled = _mcp_method(dash_duo.server_url, "tasks/cancel", {"taskId": task_id})
121+
assert cancelled["result"]["status"] == "cancelled"
122+
assert not manager.job_running(job_id)
123+
124+
result = _mcp_method(dash_duo.server_url, "tasks/result", {"taskId": task_id})
125+
assert result["result"]["structuredContent"] == {"multi": True}
126+
assert dash_duo.get_logs() == []
127+
128+
92129
def test_mcpbg012_trigger_poll_and_retrieve():
93130
app = _make_background_app()
94131
client = app.server.test_client()

‎tests/unit/mcp/tools/test_mcp_background_callbacks.py‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ def _trigger_task(app):
9494
def _wait_for_completion(app, task_id, timeout=3):
9595
"""Block until the callback manager reports the job is no longer running."""
9696
job_id, _ = _unwrap_handles(app, task_id)
97-
manager = app.callback_map["output.children"]["manager"]
97+
tool_name = task_id.split(":", 1)[0]
98+
adapter = app.mcp_callback_map.find_by_tool_name(tool_name)
99+
manager = app.callback_map[adapter.output_id]["manager"]
98100
deadline = time.time() + timeout
99101
while time.time() < deadline:
100102
if not manager.job_running(job_id):
@@ -298,3 +300,64 @@ def test_mcpbg011_task_id_encodes_tool_name_job_id_cache_key():
298300
assert job_id.isdigit()
299301
assert len(cache_key) == 64 # SHA256 hex
300302
assert created_epoch.isdigit()
303+
304+
305+
def _make_no_output_background_app():
306+
cache = diskcache.Cache()
307+
manager = DiskcacheManager(cache)
308+
309+
app = Dash(__name__)
310+
app.layout = html.Div(
311+
[
312+
html.Div(id="trigger-no-output"),
313+
html.Div(id="side-effect-target"),
314+
]
315+
)
316+
317+
@app.callback(
318+
Input("trigger-no-output", "children"),
319+
background=True,
320+
manager=manager,
321+
)
322+
def no_output_slow_callback(value):
323+
time.sleep(0.5)
324+
325+
return app
326+
327+
328+
def test_mcpbg012_no_output_task_result_does_not_raise():
329+
app = _make_no_output_background_app()
330+
create_result = _mcp(
331+
app,
332+
"tools/call",
333+
{
334+
"name": "no_output_slow_callback",
335+
"arguments": {"value": "hello"},
336+
"task": {"ttl": 60000},
337+
},
338+
)
339+
task_id = create_result["result"]["task"]["taskId"]
340+
_wait_for_completion(app, task_id)
341+
342+
result = _mcp(app, "tasks/result", {"taskId": task_id})
343+
assert result["result"]["structuredContent"] == {"multi": True}
344+
345+
346+
def test_mcpbg013_cancel_no_output_task_does_not_raise():
347+
app = _make_no_output_background_app()
348+
create_result = _mcp(
349+
app,
350+
"tools/call",
351+
{
352+
"name": "no_output_slow_callback",
353+
"arguments": {"value": "hello"},
354+
"task": {"ttl": 60000},
355+
},
356+
)
357+
task_id = create_result["result"]["task"]["taskId"]
358+
359+
cancel_result = _mcp(app, "tasks/cancel", {"taskId": task_id})
360+
assert "error" not in cancel_result
361+
362+
result = _mcp(app, "tasks/result", {"taskId": task_id})
363+
assert result["result"]["structuredContent"] == {"multi": True}

0 commit comments

Comments
 (0)