Skip to content

Commit f1b6306

Browse files
authored
Merge pull request #3771 from plotly/persistent-callbacks
[4.2] Persistent callbacks
2 parents 4b2b1a8 + 78cf94a commit f1b6306

17 files changed

Lines changed: 379 additions & 24 deletions

.ai/ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -963,7 +963,7 @@ Renderer SharedWorker Server
963963

964964
### Long-Running Callbacks with set_props/get_props
965965

966-
WebSocket callbacks can stream updates to the client during execution using `set_props()` and read current component values using `ctx.get_websocket()`:
966+
WebSocket callbacks can stream updates to the client during execution using `set_props()` and read current component values using `ctx.websocket`:
967967

968968
```python
969969
import asyncio
@@ -975,7 +975,7 @@ from dash import callback, Output, Input, set_props, ctx
975975
prevent_initial_call=True
976976
)
977977
async def long_running_task(n_clicks):
978-
ws = ctx.get_websocket()
978+
ws = ctx.websocket
979979
if not ws:
980980
return "WebSocket not available"
981981

@@ -993,7 +993,7 @@ async def long_running_task(n_clicks):
993993

994994
**API:**
995995
- `set_props(component_id, props_dict)` - Stream prop updates immediately to client
996-
- `ctx.get_websocket()` - Get WebSocket interface (returns `None` if not in WS context)
996+
- `ctx.websocket` - Get WebSocket interface (returns `None` if not in WS context)
997997
- `await ws.get_prop(component_id, prop_name)` - Read current prop value from client
998998
- `await ws.set_prop(component_id, prop_name, value)` - Set single prop (async version)
999999
- `await ws.close(code, reason)` - Close the WebSocket connection

dash/_callback.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def callback(
7878
optional: Optional[bool] = False,
7979
hidden: Optional[bool] = None,
8080
websocket: Optional[bool] = False,
81+
persistent: Optional[bool] = False,
8182
**_kwargs,
8283
) -> Callable[..., Any]:
8384
"""
@@ -172,6 +173,10 @@ def callback(
172173
The endpoint is relative to the Dash app's base URL.
173174
Note that the endpoint will not appear in the list of registered
174175
callbacks in the Dash devtools.
176+
:param persistent:
177+
If True, this callback will not show the "Updating..." title while
178+
running. Useful for persistent WebSocket callbacks that stay active
179+
for long periods without requiring a loading indicator.
175180
"""
176181

177182
background_spec: Any = None
@@ -230,6 +235,7 @@ def callback(
230235
optional=optional,
231236
hidden=hidden,
232237
websocket=websocket,
238+
persistent=persistent,
233239
)
234240

235241

@@ -278,6 +284,7 @@ def insert_callback(
278284
optional=False,
279285
hidden=None,
280286
websocket=False,
287+
persistent=False,
281288
) -> str:
282289
if prevent_initial_call is None:
283290
prevent_initial_call = config_prevent_initial_callbacks
@@ -304,6 +311,7 @@ def insert_callback(
304311
"optional": optional,
305312
"hidden": hidden,
306313
"websocket": websocket,
314+
"persistent": persistent,
307315
}
308316
if running:
309317
callback_spec["running"] = running
@@ -658,6 +666,7 @@ def register_callback(
658666
optional=_kwargs.get("optional", False),
659667
hidden=_kwargs.get("hidden", None),
660668
websocket=_kwargs.get("websocket", False),
669+
persistent=_kwargs.get("persistent", False),
661670
)
662671

663672
# pylint: disable=too-many-locals

dash/_callback_context.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ def custom_data(self):
330330

331331
@property
332332
@has_context
333-
def get_websocket(self) -> typing.Optional[DashWebsocketCallback]:
333+
def websocket(self) -> typing.Optional[DashWebsocketCallback]:
334334
"""Get WebSocket interface if running in WebSocket context.
335335
336336
Returns the DashWebsocketCallback instance if the callback is being

dash/_utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,20 @@ def _concat(x):
165165

166166
if no_output:
167167
# No output will hash the inputs.
168+
# For no-input callbacks, also include the call site to make each unique
169+
if not inputs:
170+
# Get the call site of the @callback decorator
171+
stack = inspect.stack()
172+
# Walk up the stack to find the actual callback call site
173+
# Fallback to empty hash if no external frame found
174+
# (skip internal dash package frames)
175+
dash_package_path = os.path.dirname(__file__)
176+
for frame_info in stack:
177+
# Skip frames from within the dash package itself
178+
if not frame_info.filename.startswith(dash_package_path):
179+
call_site = f"{frame_info.filename}:{frame_info.lineno}"
180+
return hashlib.sha256(call_site.encode("utf-8")).hexdigest()
181+
168182
return _hash_inputs()
169183

170184
if isinstance(output, (list, tuple)):

dash/dash-renderer/src/actions/dependencies.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,14 +224,17 @@ function validateDependencies(parsedDependencies, dispatchError) {
224224
'In the callback for output(s):\n ' +
225225
outputs.map(combineIdAndProp).join('\n ');
226226

227-
if (!inputs.length) {
227+
if (!inputs.length && dep.prevent_initial_call) {
228228
dispatchError('A callback is missing Inputs', [
229229
head,
230230
'there are no `Input` elements.',
231231
'Without `Input` elements, it will never get called.',
232232
'',
233233
'Subscribing to `Input` components will cause the',
234-
'callback to be called whenever their values change.'
234+
'callback to be called whenever their values change.',
235+
'',
236+
'If you want a callback without inputs that fires on initial load,',
237+
'set prevent_initial_call=False.'
235238
]);
236239
}
237240

dash/dash-renderer/src/actions/dependencies_ts.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -352,12 +352,18 @@ export const getLayoutCallbacks = (
352352

353353
export const getUniqueIdentifier = ({
354354
anyVals,
355-
callback: {inputs, outputs, state}
356-
}: ICallback): string =>
357-
concat(
358-
map(combineIdAndProp, [...inputs, ...outputs, ...state]),
355+
callback: {inputs, outputs, state, output}
356+
}: ICallback): string => {
357+
const idParts = map(combineIdAndProp, [...inputs, ...outputs, ...state]);
358+
// For no-output callbacks, include the output hash to ensure uniqueness
359+
if (outputs.length === 0 && output) {
360+
idParts.push(output);
361+
}
362+
return concat(
363+
idParts,
359364
Array.isArray(anyVals) ? anyVals : anyVals === '' ? [] : [anyVals]
360365
).join(',');
366+
};
361367

362368
export function includeObservers(
363369
id: any,

dash/dash-renderer/src/actions/index.js

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import {getAppState} from '../reducers/constants';
55
import {getAction} from './constants';
66
import * as cookie from 'cookie';
77
import {validateCallbacksToLayout} from './dependencies';
8-
import {includeObservers, getLayoutCallbacks} from './dependencies_ts';
8+
import {
9+
includeObservers,
10+
getLayoutCallbacks,
11+
makeResolvedCallback,
12+
resolveDeps
13+
} from './dependencies_ts';
914
import {computePaths, getPath} from './paths';
1015
import {recordUiEdit} from '../persistence';
1116

@@ -95,13 +100,59 @@ function triggerDefaultState(dispatch, getState) {
95100
);
96101
}
97102

98-
dispatch(
99-
addRequestedCallbacks(
100-
getLayoutCallbacks(graphs, paths, layout.components, {
101-
outputsOnly: true
102-
})
103-
)
103+
const layoutCallbacks = getLayoutCallbacks(
104+
graphs,
105+
paths,
106+
layout.components,
107+
{
108+
outputsOnly: true
109+
}
104110
);
111+
112+
// Also include no-output and no-input callbacks that should fire on initial load
113+
const specialCallbacks = (graphs.callbacks || []).reduce((acc, cb) => {
114+
if (cb.prevent_initial_call) {
115+
return acc;
116+
}
117+
118+
const isNoOutput = cb.noOutput;
119+
const isNoInput = !cb.noOutput && cb.inputs.length === 0;
120+
121+
if (!isNoOutput && !isNoInput) {
122+
return acc;
123+
}
124+
125+
const resolved = makeResolvedCallback(cb, resolveDeps(), '');
126+
resolved.initialCall = true;
127+
128+
if (isNoOutput) {
129+
// No-output: include if no inputs or any input is in layout
130+
if (cb.inputs.length === 0) {
131+
acc.push(resolved);
132+
} else {
133+
const inputs = resolved.getInputs(paths);
134+
if (
135+
inputs.some(inp =>
136+
Array.isArray(inp) ? inp.length > 0 : inp
137+
)
138+
) {
139+
acc.push(resolved);
140+
}
141+
}
142+
} else {
143+
// No-input: include if any output is in layout
144+
const outputs = resolved.getOutputs(paths);
145+
if (
146+
outputs.some(out => (Array.isArray(out) ? out.length > 0 : out))
147+
) {
148+
acc.push(resolved);
149+
}
150+
}
151+
152+
return acc;
153+
}, []);
154+
155+
dispatch(addRequestedCallbacks([...layoutCallbacks, ...specialCallbacks]));
105156
}
106157

107158
export const redo = moveHistory('REDO');

dash/dash-renderer/src/observers/isLoading.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ const observer: IStoreObserverDefinition<IStoreState> = {
99

1010
const pendingCallbacks = getPendingCallbacks(callbacks);
1111

12-
const next = Boolean(pendingCallbacks.length);
12+
// Filter out persistent callbacks - they shouldn't trigger the loading indicator
13+
const nonPersistentCallbacks = pendingCallbacks.filter(
14+
cb => !cb.callback.persistent
15+
);
16+
17+
const next = Boolean(nonPersistentCallbacks.length);
1318

1419
if (isLoading !== next) {
1520
dispatch(setIsLoading(next));

dash/dash-renderer/src/types/callbacks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface ICallbackDefinition {
1616
running: any;
1717
no_output?: boolean;
1818
websocket?: boolean;
19+
persistent?: boolean;
1920
}
2021

2122
export interface ICallbackProperty {

0 commit comments

Comments
 (0)