diff --git a/.ai/ARCHITECTURE.md b/.ai/ARCHITECTURE.md index db40397782..84f553978c 100644 --- a/.ai/ARCHITECTURE.md +++ b/.ai/ARCHITECTURE.md @@ -963,7 +963,7 @@ Renderer SharedWorker Server ### Long-Running Callbacks with set_props/get_props -WebSocket callbacks can stream updates to the client during execution using `set_props()` and read current component values using `ctx.get_websocket()`: +WebSocket callbacks can stream updates to the client during execution using `set_props()` and read current component values using `ctx.websocket`: ```python import asyncio @@ -975,7 +975,7 @@ from dash import callback, Output, Input, set_props, ctx prevent_initial_call=True ) async def long_running_task(n_clicks): - ws = ctx.get_websocket() + ws = ctx.websocket if not ws: return "WebSocket not available" @@ -993,7 +993,7 @@ async def long_running_task(n_clicks): **API:** - `set_props(component_id, props_dict)` - Stream prop updates immediately to client -- `ctx.get_websocket()` - Get WebSocket interface (returns `None` if not in WS context) +- `ctx.websocket` - Get WebSocket interface (returns `None` if not in WS context) - `await ws.get_prop(component_id, prop_name)` - Read current prop value from client - `await ws.set_prop(component_id, prop_name, value)` - Set single prop (async version) - `await ws.close(code, reason)` - Close the WebSocket connection diff --git a/dash/_callback.py b/dash/_callback.py index 718a016d82..f5f64970b0 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -78,6 +78,7 @@ def callback( optional: Optional[bool] = False, hidden: Optional[bool] = None, websocket: Optional[bool] = False, + persistent: Optional[bool] = False, **_kwargs, ) -> Callable[..., Any]: """ @@ -172,6 +173,10 @@ def callback( The endpoint is relative to the Dash app's base URL. Note that the endpoint will not appear in the list of registered callbacks in the Dash devtools. + :param persistent: + If True, this callback will not show the "Updating..." title while + running. Useful for persistent WebSocket callbacks that stay active + for long periods without requiring a loading indicator. """ background_spec: Any = None @@ -230,6 +235,7 @@ def callback( optional=optional, hidden=hidden, websocket=websocket, + persistent=persistent, ) @@ -278,6 +284,7 @@ def insert_callback( optional=False, hidden=None, websocket=False, + persistent=False, ) -> str: if prevent_initial_call is None: prevent_initial_call = config_prevent_initial_callbacks @@ -304,6 +311,7 @@ def insert_callback( "optional": optional, "hidden": hidden, "websocket": websocket, + "persistent": persistent, } if running: callback_spec["running"] = running @@ -658,6 +666,7 @@ def register_callback( optional=_kwargs.get("optional", False), hidden=_kwargs.get("hidden", None), websocket=_kwargs.get("websocket", False), + persistent=_kwargs.get("persistent", False), ) # pylint: disable=too-many-locals diff --git a/dash/_callback_context.py b/dash/_callback_context.py index e03f343129..809def45ef 100644 --- a/dash/_callback_context.py +++ b/dash/_callback_context.py @@ -328,7 +328,7 @@ def custom_data(self): @property @has_context - def get_websocket(self) -> typing.Optional[DashWebsocketCallback]: + def websocket(self) -> typing.Optional[DashWebsocketCallback]: """Get WebSocket interface if running in WebSocket context. Returns the DashWebsocketCallback instance if the callback is being diff --git a/dash/_utils.py b/dash/_utils.py index 5e241fe21d..85ff9ab073 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -165,6 +165,20 @@ def _concat(x): if no_output: # No output will hash the inputs. + # For no-input callbacks, also include the call site to make each unique + if not inputs: + # Get the call site of the @callback decorator + stack = inspect.stack() + # Walk up the stack to find the actual callback call site + # (skip internal dash package frames) + dash_package_path = os.path.dirname(__file__) + for frame_info in stack: + # Skip frames from within the dash package itself + if not frame_info.filename.startswith(dash_package_path): + call_site = f"{frame_info.filename}:{frame_info.lineno}" + return hashlib.sha256(call_site.encode("utf-8")).hexdigest() + # Fallback to empty hash if no external frame found + return _hash_inputs() return _hash_inputs() if isinstance(output, (list, tuple)): diff --git a/dash/dash-renderer/src/actions/dependencies.js b/dash/dash-renderer/src/actions/dependencies.js index 7b5d1665f0..fa29199a1d 100644 --- a/dash/dash-renderer/src/actions/dependencies.js +++ b/dash/dash-renderer/src/actions/dependencies.js @@ -224,14 +224,17 @@ function validateDependencies(parsedDependencies, dispatchError) { 'In the callback for output(s):\n ' + outputs.map(combineIdAndProp).join('\n '); - if (!inputs.length) { + if (!inputs.length && dep.prevent_initial_call) { dispatchError('A callback is missing Inputs', [ head, 'there are no `Input` elements.', 'Without `Input` elements, it will never get called.', '', 'Subscribing to `Input` components will cause the', - 'callback to be called whenever their values change.' + 'callback to be called whenever their values change.', + '', + 'If you want a callback without inputs that fires on initial load,', + 'set prevent_initial_call=False.' ]); } diff --git a/dash/dash-renderer/src/actions/dependencies_ts.ts b/dash/dash-renderer/src/actions/dependencies_ts.ts index 33f968cf91..4056cdeac1 100644 --- a/dash/dash-renderer/src/actions/dependencies_ts.ts +++ b/dash/dash-renderer/src/actions/dependencies_ts.ts @@ -352,12 +352,18 @@ export const getLayoutCallbacks = ( export const getUniqueIdentifier = ({ anyVals, - callback: {inputs, outputs, state} -}: ICallback): string => - concat( - map(combineIdAndProp, [...inputs, ...outputs, ...state]), + callback: {inputs, outputs, state, output} +}: ICallback): string => { + const idParts = map(combineIdAndProp, [...inputs, ...outputs, ...state]); + // For no-output callbacks, include the output hash to ensure uniqueness + if (outputs.length === 0 && output) { + idParts.push(output); + } + return concat( + idParts, Array.isArray(anyVals) ? anyVals : anyVals === '' ? [] : [anyVals] ).join(','); +}; export function includeObservers( id: any, diff --git a/dash/dash-renderer/src/actions/index.js b/dash/dash-renderer/src/actions/index.js index 6169c4f65e..51229767ba 100644 --- a/dash/dash-renderer/src/actions/index.js +++ b/dash/dash-renderer/src/actions/index.js @@ -5,7 +5,12 @@ import {getAppState} from '../reducers/constants'; import {getAction} from './constants'; import * as cookie from 'cookie'; import {validateCallbacksToLayout} from './dependencies'; -import {includeObservers, getLayoutCallbacks} from './dependencies_ts'; +import { + includeObservers, + getLayoutCallbacks, + makeResolvedCallback, + resolveDeps +} from './dependencies_ts'; import {computePaths, getPath} from './paths'; import {recordUiEdit} from '../persistence'; @@ -95,12 +100,62 @@ function triggerDefaultState(dispatch, getState) { ); } - dispatch( - addRequestedCallbacks( - getLayoutCallbacks(graphs, paths, layout.components, { - outputsOnly: true - }) + const layoutCallbacks = getLayoutCallbacks( + graphs, + paths, + layout.components, + { + outputsOnly: true + } + ); + + // Also include no-output callbacks whose inputs are in the layout (or have no inputs) + const noOutputCallbacks = (graphs.callbacks || []) + .filter(cb => cb.noOutput && !cb.prevent_initial_call) + .map(cb => { + const resolved = makeResolvedCallback(cb, resolveDeps(), ''); + resolved.initialCall = true; + return resolved; + }) + .filter(cb => { + // If no inputs, always include (fires once on initial load) + if (cb.callback.inputs.length === 0) { + return true; + } + // Check if any input is in the layout + const inputs = cb.getInputs(paths); + return inputs.some(inp => + Array.isArray(inp) ? inp.length > 0 : inp + ); + }); + + // Also include no-input callbacks (with outputs) that should fire on initial load + const noInputCallbacks = (graphs.callbacks || []) + .filter( + cb => + !cb.noOutput && + cb.inputs.length === 0 && + !cb.prevent_initial_call ) + .map(cb => { + const resolved = makeResolvedCallback(cb, resolveDeps(), ''); + resolved.initialCall = true; + return resolved; + }) + .filter(cb => { + // Check if any output is in the layout + const outputs = cb.getOutputs(paths); + return outputs.some(out => + Array.isArray(out) ? out.length > 0 : out + ); + }); + + dispatch( + addRequestedCallbacks([ + ...layoutCallbacks, + ...noOutputCallbacks, + ...noInputCallbacks + ]) ); } diff --git a/dash/dash-renderer/src/observers/isLoading.ts b/dash/dash-renderer/src/observers/isLoading.ts index 687f607378..cc3bf193b8 100644 --- a/dash/dash-renderer/src/observers/isLoading.ts +++ b/dash/dash-renderer/src/observers/isLoading.ts @@ -9,7 +9,12 @@ const observer: IStoreObserverDefinition = { const pendingCallbacks = getPendingCallbacks(callbacks); - const next = Boolean(pendingCallbacks.length); + // Filter out persistent callbacks - they shouldn't trigger the loading indicator + const nonPersistentCallbacks = pendingCallbacks.filter( + cb => !cb.callback.persistent + ); + + const next = Boolean(nonPersistentCallbacks.length); if (isLoading !== next) { dispatch(setIsLoading(next)); diff --git a/dash/dash-renderer/src/types/callbacks.ts b/dash/dash-renderer/src/types/callbacks.ts index 38a5d7d82f..5f963463d2 100644 --- a/dash/dash-renderer/src/types/callbacks.ts +++ b/dash/dash-renderer/src/types/callbacks.ts @@ -16,6 +16,7 @@ export interface ICallbackDefinition { running: any; no_output?: boolean; websocket?: boolean; + persistent?: boolean; } export interface ICallbackProperty { diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index 87ce3507e7..6e724c186f 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -917,3 +917,194 @@ def on_click(_): assert error.text == error_title for error_text in dash_duo.find_elements(".dash-backend-error"): assert all(line in error_text for line in error_message) + + +def test_cbsc022_no_output_callback_initial_call(dash_duo): + """Test that no-output callbacks fire on initial load.""" + + call_count = Value("i", 0) + + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("Click", id="btn", n_clicks=0), + html.Div(id="output"), + ] + ) + + @app.callback( + Input("btn", "n_clicks"), + ) + def no_output_callback(n_clicks): + call_count.value += 1 + + @app.callback( + Output("output", "children"), + Input("btn", "n_clicks"), + ) + def with_output_callback(n_clicks): + return f"Clicks: {n_clicks}" + + dash_duo.start_server(app) + + # Wait for initial render + dash_duo.wait_for_text_to_equal("#output", "Clicks: 0") + + # No-output callback should have fired on initial load + assert call_count.value == 1, "no-output callback should fire on initial load" + + # Click button + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#output", "Clicks: 1") + + # No-output callback should have fired again + assert call_count.value == 2, "no-output callback should fire on click" + + assert dash_duo.get_logs() == [] + + +def test_cbsc023_no_input_callback_initial_call(dash_duo): + """Test that no-input callbacks fire on initial load (issue #3411).""" + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Store(id="store", data="initial"), + html.Div(id="output"), + ] + ) + + @app.callback( + Output("output", "children"), + State("store", "data"), + ) + def no_input_callback(data): + return f"Data: {data}" + + dash_duo.start_server(app) + + # No-input callback should fire on initial load + dash_duo.wait_for_text_to_equal("#output", "Data: initial") + + assert dash_duo.get_logs() == [] + + +def test_cbsc024_no_input_no_output_callback_initial_call(dash_duo): + """Test that callbacks with no input and no output fire on initial load.""" + from multiprocessing import Value + + call_count = Value("i", 0) + + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div(id="output", children="Waiting..."), + ] + ) + + @app.callback() + def no_input_no_output_callback(): + call_count.value += 1 + print(f"No-input no-output callback fired: {call_count.value}") + + dash_duo.start_server(app) + + # Give it time to fire + dash_duo.wait_for_element("#output") + time.sleep(0.5) + + # Callback should have fired on initial load + assert ( + call_count.value == 1 + ), "no-input no-output callback should fire on initial load" + + assert dash_duo.get_logs() == [] + + +def test_cbsc025_multiple_no_input_no_output_callbacks(dash_duo): + """Test that multiple no-input no-output callbacks all fire on initial load.""" + from multiprocessing import Value + + call_count_1 = Value("i", 0) + call_count_2 = Value("i", 0) + call_count_3 = Value("i", 0) + + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div(id="output", children="Waiting..."), + ] + ) + + @app.callback() + def first_callback(): + call_count_1.value += 1 + + @app.callback() + def second_callback(): + call_count_2.value += 1 + + @app.callback() + def third_callback(): + call_count_3.value += 1 + + dash_duo.start_server(app) + + # Give callbacks time to fire + dash_duo.wait_for_element("#output") + time.sleep(0.5) + + # All callbacks should have fired on initial load + assert call_count_1.value == 1, "first callback should fire" + assert call_count_2.value == 1, "second callback should fire" + assert call_count_3.value == 1, "third callback should fire" + + assert dash_duo.get_logs() == [] + + +def test_cbsc026_no_input_with_duplicate_outputs(dash_duo): + """Test no-input callbacks with duplicate outputs.""" + from multiprocessing import Value + + call_count_1 = Value("i", 0) + call_count_2 = Value("i", 0) + + app = Dash(__name__) + app.layout = html.Div( + [ + dcc.Store(id="store", data="initial"), + html.Div(id="output", children="Waiting..."), + ] + ) + + @app.callback( + Output("output", "children"), + State("store", "data"), + ) + def first_no_input_callback(data): + call_count_1.value += 1 + return f"First: {data}" + + @app.callback( + Output("output", "children", allow_duplicate=True), + State("store", "data"), + prevent_initial_call="initial_duplicate", + ) + def second_no_input_callback(data): + call_count_2.value += 1 + return f"Second: {data}" + + dash_duo.start_server(app) + + # Give callbacks time to fire + dash_duo.wait_for_element("#output") + time.sleep(0.5) + + # Both callbacks should have fired on initial load + assert call_count_1.value == 1, "first no-input callback should fire" + assert call_count_2.value == 1, "second no-input callback should fire" + + # Output should contain result from one of the callbacks + output_text = dash_duo.find_element("#output").text + assert "initial" in output_text, "output should contain data from store" + + assert dash_duo.get_logs() == [] diff --git a/tests/integration/devtools/test_callback_validation.py b/tests/integration/devtools/test_callback_validation.py index eaee814980..8501821886 100644 --- a/tests/integration/devtools/test_callback_validation.py +++ b/tests/integration/devtools/test_callback_validation.py @@ -69,10 +69,26 @@ def check_errors(dash_duo, specs): def test_dvcv001_blank(dash_duo): + """No-input no-output callbacks are allowed when prevent_initial_call=False (default).""" app = Dash(__name__) app.layout = html.Div() - @app.callback([], []) + @app.callback() + def x(): + pass # No-output callbacks shouldn't return anything + + dash_duo.start_server(app, **debugging) + # No errors expected - no-input callbacks are allowed when prevent_initial_call=False + dash_duo.wait_for_element("div") + assert dash_duo.get_logs() == [] + + +def test_dvcv001b_blank_prevent_initial_call(dash_duo): + """No-input callbacks should error when prevent_initial_call=True.""" + app = Dash(__name__) + app.layout = html.Div() + + @app.callback([], [], prevent_initial_call=True) def x(): return 42 diff --git a/tests/integration/renderer/test_loading_states.py b/tests/integration/renderer/test_loading_states.py index 169b505ed1..9818902f61 100644 --- a/tests/integration/renderer/test_loading_states.py +++ b/tests/integration/renderer/test_loading_states.py @@ -298,3 +298,61 @@ def update(n): dash_duo.wait_for_text_to_equal("#final-output", "1") until(lambda: dash_duo.driver.title == "Page 1", timeout=1) + + +def test_rdls005_persistent_callback_no_update_title(dash_duo): + """Test that persistent=True callbacks don't trigger the 'Updating...' title.""" + lock = Lock() + + app = Dash(__name__) + + app.layout = html.Div( + children=[ + html.H3("Test persistent callback"), + html.Button("Persistent", id="persistent-btn", n_clicks=0), + html.Button("Regular", id="regular-btn", n_clicks=0), + html.Div(id="persistent-output"), + html.Div(id="regular-output"), + ] + ) + + @app.callback( + Output("persistent-output", "children"), + Input("persistent-btn", "n_clicks"), + persistent=True, + ) + def persistent_update(n): + with lock: + return f"Persistent: {n}" + + @app.callback( + Output("regular-output", "children"), + Input("regular-btn", "n_clicks"), + ) + def regular_update(n): + with lock: + return f"Regular: {n}" + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#persistent-output", "Persistent: 0") + dash_duo.wait_for_text_to_equal("#regular-output", "Regular: 0") + + # Verify title is "Dash" after initial load + until(lambda: dash_duo.driver.title == "Dash", timeout=1) + + # Test that persistent callback does NOT change title to "Updating..." + with lock: + dash_duo.find_element("#persistent-btn").click() + # Title should remain "Dash" even while callback is running + until(lambda: dash_duo.driver.title == "Dash", timeout=1) + + dash_duo.wait_for_text_to_equal("#persistent-output", "Persistent: 1") + + # Test that regular callback DOES change title to "Updating..." + with lock: + dash_duo.find_element("#regular-btn").click() + until(lambda: dash_duo.driver.title == "Updating...", timeout=1) + + dash_duo.wait_for_text_to_equal("#regular-output", "Regular: 1") + # Title should revert after callback completes + until(lambda: dash_duo.driver.title == "Dash", timeout=1) diff --git a/tests/integration/renderer/test_render_type.py b/tests/integration/renderer/test_render_type.py index 17a6cfbae3..417be6e586 100644 --- a/tests/integration/renderer/test_render_type.py +++ b/tests/integration/renderer/test_render_type.py @@ -25,6 +25,7 @@ def test_rtype001_rendertype(dash_duo): dash_clientside.set_props('render_test', {n_clicks: 20}) }""", Input("clientside_render", "n_clicks"), + prevent_initial_call=True, ) @app.callback( diff --git a/tests/websocket/test_ws_basic.py b/tests/websocket/test_ws_basic.py index 935d633339..1d74706a68 100644 --- a/tests/websocket/test_ws_basic.py +++ b/tests/websocket/test_ws_basic.py @@ -179,7 +179,7 @@ def test_ws005_websocket_context_available(dash_duo): def check_context(n_clicks): if not n_clicks: return "Click to check" - ws = ctx.get_websocket + ws = ctx.websocket if ws is not None: return "WebSocket context available" return "No WebSocket context" diff --git a/tests/websocket/test_ws_props.py b/tests/websocket/test_ws_props.py index e800668ae8..a86402954d 100644 --- a/tests/websocket/test_ws_props.py +++ b/tests/websocket/test_ws_props.py @@ -185,7 +185,7 @@ async def read_prop(n): from dash import ctx - ws = ctx.get_websocket + ws = ctx.websocket if ws: value = await ws.get_prop("source", "children") return f"Read: {value}" @@ -219,7 +219,7 @@ async def set_via_ws(n): from dash import ctx - ws = ctx.get_websocket + ws = ctx.websocket if ws: await ws.set_prop("target", "children", f"Set via WebSocket {n}") return "Set complete" diff --git a/tests/websocket/test_ws_quart.py b/tests/websocket/test_ws_quart.py index 30e33b329c..3d40493ba5 100644 --- a/tests/websocket/test_ws_quart.py +++ b/tests/websocket/test_ws_quart.py @@ -176,7 +176,7 @@ def test_wsq005_websocket_context_available_quart(dash_duo): def check_context(n_clicks): if not n_clicks: return "Click to check" - ws = ctx.get_websocket + ws = ctx.websocket if ws is not None: return "WebSocket context available" return "No WebSocket context" diff --git a/wsapp.py b/wsapp.py index 98b2db2f38..ade2f80d39 100644 --- a/wsapp.py +++ b/wsapp.py @@ -82,7 +82,7 @@ def update_with_set_props(n_clicks): @callback(Output("output-6", "children"), Input("btn-3", "n_clicks")) def check_websocket_context(n_clicks): if n_clicks > 0: - ws = ctx.get_websocket + ws = ctx.websocket if ws is not None: return f"WebSocket context is available! (click {n_clicks})" else: