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/_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/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)