From c001bb19dbd2a4969beff8c83ce7f20c09994966 Mon Sep 17 00:00:00 2001 From: 0d00ciallo0721 <259874237+0d00ciallo0721@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:23:20 +0800 Subject: [PATCH 1/2] fix(renderer): clear child paths when callback returns None --- .../src/observers/executedCallbacks.ts | 2 +- .../callbacks/test_children_none.py | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/integration/callbacks/test_children_none.py diff --git a/dash/dash-renderer/src/observers/executedCallbacks.ts b/dash/dash-renderer/src/observers/executedCallbacks.ts index 11fdcd023c..32514f67ba 100644 --- a/dash/dash-renderer/src/observers/executedCallbacks.ts +++ b/dash/dash-renderer/src/observers/executedCallbacks.ts @@ -321,7 +321,7 @@ const observer: IStoreObserverDefinition = { childrenPropPath, appliedProps ); - if (!children) { + if (children === undefined) { return; } diff --git a/tests/integration/callbacks/test_children_none.py b/tests/integration/callbacks/test_children_none.py new file mode 100644 index 0000000000..6e00c8c47c --- /dev/null +++ b/tests/integration/callbacks/test_children_none.py @@ -0,0 +1,50 @@ +import time + +from dash import Dash, Input, Output, dcc, html +from dash.testing.wait import until + + +def test_callback_returning_none_removes_child_paths_and_can_remount(dash_duo): + view_calls = [] + + app = Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div( + [ + html.Button("Toggle", id="toggle"), + dcc.Interval(id="tick", interval=200), + html.Div(id="container"), + ] + ) + + @app.callback( + Output("container", "children"), + Input("toggle", "n_clicks"), + prevent_initial_call=True, + ) + def toggle_view(n_clicks): + if n_clicks % 2: + return html.Div(html.Span(id="view-child"), id="view") + return None + + @app.callback(Output("view-child", "children"), Input("tick", "n_intervals")) + def update_view(n_intervals): + view_calls.append(n_intervals) + return f"tick {n_intervals}" + + dash_duo.start_server(app) + + dash_duo.find_element("#toggle").click() + dash_duo.wait_for_element("#view-child") + until(lambda: "view-child" in dash_duo.redux_state_paths["strs"], timeout=3) + until(lambda: view_calls, timeout=3) + + dash_duo.find_element("#toggle").click() + dash_duo.wait_for_no_elements("#view-child") + until(lambda: "view-child" not in dash_duo.redux_state_paths["strs"], timeout=3) + calls_after_unmount = len(view_calls) + time.sleep(0.5) + assert len(view_calls) == calls_after_unmount + + dash_duo.find_element("#toggle").click() + dash_duo.wait_for_element("#view-child") + until(lambda: "view-child" in dash_duo.redux_state_paths["strs"], timeout=3) From b0998376b92d5a4d6c5c1eace372cdf351262b34 Mon Sep 17 00:00:00 2001 From: 0d00ciallo0721 <259874237+0d00ciallo0721@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:48:51 +0800 Subject: [PATCH 2/2] test(renderer): cover falsy callback children --- .../callbacks/test_children_none.py | 50 ---------------- .../test_layout_paths_with_callbacks.py | 58 +++++++++++++++++++ 2 files changed, 58 insertions(+), 50 deletions(-) delete mode 100644 tests/integration/callbacks/test_children_none.py diff --git a/tests/integration/callbacks/test_children_none.py b/tests/integration/callbacks/test_children_none.py deleted file mode 100644 index 6e00c8c47c..0000000000 --- a/tests/integration/callbacks/test_children_none.py +++ /dev/null @@ -1,50 +0,0 @@ -import time - -from dash import Dash, Input, Output, dcc, html -from dash.testing.wait import until - - -def test_callback_returning_none_removes_child_paths_and_can_remount(dash_duo): - view_calls = [] - - app = Dash(__name__, suppress_callback_exceptions=True) - app.layout = html.Div( - [ - html.Button("Toggle", id="toggle"), - dcc.Interval(id="tick", interval=200), - html.Div(id="container"), - ] - ) - - @app.callback( - Output("container", "children"), - Input("toggle", "n_clicks"), - prevent_initial_call=True, - ) - def toggle_view(n_clicks): - if n_clicks % 2: - return html.Div(html.Span(id="view-child"), id="view") - return None - - @app.callback(Output("view-child", "children"), Input("tick", "n_intervals")) - def update_view(n_intervals): - view_calls.append(n_intervals) - return f"tick {n_intervals}" - - dash_duo.start_server(app) - - dash_duo.find_element("#toggle").click() - dash_duo.wait_for_element("#view-child") - until(lambda: "view-child" in dash_duo.redux_state_paths["strs"], timeout=3) - until(lambda: view_calls, timeout=3) - - dash_duo.find_element("#toggle").click() - dash_duo.wait_for_no_elements("#view-child") - until(lambda: "view-child" not in dash_duo.redux_state_paths["strs"], timeout=3) - calls_after_unmount = len(view_calls) - time.sleep(0.5) - assert len(view_calls) == calls_after_unmount - - dash_duo.find_element("#toggle").click() - dash_duo.wait_for_element("#view-child") - until(lambda: "view-child" in dash_duo.redux_state_paths["strs"], timeout=3) diff --git a/tests/integration/callbacks/test_layout_paths_with_callbacks.py b/tests/integration/callbacks/test_layout_paths_with_callbacks.py index 7ce0296bc4..602635e25d 100644 --- a/tests/integration/callbacks/test_layout_paths_with_callbacks.py +++ b/tests/integration/callbacks/test_layout_paths_with_callbacks.py @@ -1,6 +1,9 @@ import os import json from multiprocessing import Value +import time + +import pytest from dash import Dash, Input, Output, dcc, html import dash.testing.wait as wait @@ -248,3 +251,58 @@ def check_call_counts(chapters, count): ) check_chapter("chapter1") snapshot(name="chapter-1-again") + + +@pytest.mark.parametrize( + "replacement", [None, "", 0], ids=["none", "empty-string", "zero"] +) +def test_cblp002_callback_replaces_children_with_falsy_value_and_can_remount( + dash_duo, replacement +): + view_calls = [] + + app = Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div( + [ + html.Button("Toggle", id="toggle"), + dcc.Interval(id="tick", interval=200), + html.Div(id="container"), + ] + ) + + @app.callback( + Output("container", "children"), + Input("toggle", "n_clicks"), + prevent_initial_call=True, + ) + def toggle_view(n_clicks): + if n_clicks % 2: + return html.Div(html.Span(id="view-child"), id="view") + return replacement + + @app.callback(Output("view-child", "children"), Input("tick", "n_intervals")) + def update_view(n_intervals): + view_calls.append(n_intervals) + return f"tick {n_intervals}" + + dash_duo.start_server(app) + + dash_duo.find_element("#toggle").click() + dash_duo.wait_for_element("#view-child") + wait.until(lambda: "view-child" in dash_duo.redux_state_paths["strs"], timeout=3) + dash_duo.wait_for_contains_text("#view-child", "tick") + wait.until(lambda: view_calls, timeout=3) + + dash_duo.find_element("#toggle").click() + dash_duo.wait_for_no_elements("#view-child") + wait.until( + lambda: "view-child" not in dash_duo.redux_state_paths["strs"], timeout=3 + ) + calls_after_unmount = len(view_calls) + time.sleep(0.5) + assert len(view_calls) == calls_after_unmount + + dash_duo.find_element("#toggle").click() + dash_duo.wait_for_element("#view-child") + wait.until(lambda: "view-child" in dash_duo.redux_state_paths["strs"], timeout=3) + dash_duo.wait_for_contains_text("#view-child", "tick")