Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .ai/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -230,6 +235,7 @@ def callback(
optional=optional,
hidden=hidden,
websocket=websocket,
persistent=persistent,
)


Expand Down Expand Up @@ -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
Expand All @@ -304,6 +311,7 @@ def insert_callback(
"optional": optional,
"hidden": hidden,
"websocket": websocket,
"persistent": persistent,
}
if running:
callback_spec["running"] = running
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion dash/_callback_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions dash/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# (skip internal dash package frames)
# Fallback to empty hash if no external frame found
# (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()
Comment on lines +180 to +181
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Fallback to empty hash if no external frame found
return _hash_inputs()

return _hash_inputs()

if isinstance(output, (list, tuple)):
Expand Down
7 changes: 5 additions & 2 deletions dash/dash-renderer/src/actions/dependencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
]);
}

Expand Down
14 changes: 10 additions & 4 deletions dash/dash-renderer/src/actions/dependencies_ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
67 changes: 61 additions & 6 deletions dash/dash-renderer/src/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
);
});
Comment on lines +113 to +130
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You iterate through the callbacks three times. What do you think of switching to a for...of loop that does everything in one pass? In fact, you could use one loop that handles both noOutputCallbacks and noInputCallbacks.


// 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
])
);
}

Expand Down
7 changes: 6 additions & 1 deletion dash/dash-renderer/src/observers/isLoading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ const observer: IStoreObserverDefinition<IStoreState> = {

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));
Expand Down
1 change: 1 addition & 0 deletions dash/dash-renderer/src/types/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface ICallbackDefinition {
running: any;
no_output?: boolean;
websocket?: boolean;
persistent?: boolean;
}

export interface ICallbackProperty {
Expand Down
Loading
Loading