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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@
All notable changes to `dash` will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/).

## Unreleased

### Added
- [#]() Clientside callbacks now support flexible callback signatures: `output=`, `inputs=` and `state=` may be given as dicts or nested lists/tuples, like server-side callbacks. A dict grouping is passed to the JavaScript function as a single destructurable object argument, and the function returns an object matching the output grouping.
- [#]() Clientside callbacks now support `running=`, applying the on/off side updates around execution (including async/promise-returning functions and errors).
- [#]() Clientside callbacks now support `on_error=`, a JavaScript error handler given as a source string or `ClientsideFunction`. Its return value is used as the callback's outputs; returning `undefined` leaves all outputs unchanged, mirroring the server-side per-callback error handler.
- [#]() Clientside callbacks now support the `optional` keyword argument.
- [#]() `dash_clientside.callback_context` now also provides `triggered_prop_ids`, `args_grouping`, `outputs_grouping`, `using_args_grouping` and `using_outputs_grouping`, matching the server-side `callback_context`.

### Changed
- [#]() `clientside_callback` now raises a `CallbackException` when passed keyword arguments it does not support (such as `background=`, `progress=`, `cancel=` or `manager=`) instead of silently ignoring them.

## [4.4.0] - 2026-07-03

### Added
Expand Down
126 changes: 92 additions & 34 deletions dash/_callback.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from typing_extensions import ParamSpec

from .dependencies import (
handle_callback_args,
handle_grouped_callback_args,
Output,
ClientsideFunction,
Expand All @@ -28,6 +27,7 @@
flatten_grouping,
make_grouping_by_index,
grouping_len,
is_nontrivial_grouping,
)
from ._utils import (
create_callback_id,
Expand Down Expand Up @@ -269,7 +269,25 @@ def validate_background_inputs(deps):
ClientsideFuncType = Union[str, ClientsideFunction]


def _normalize_running(running):
if running is not None:
if not isinstance(running[0], (list, tuple)):
running = [running]
running = {
"running": {str(r[0]): r[1] for r in running},
"runningOff": {str(r[0]): r[2] for r in running},
}
return running


def clientside_callback(clientside_function: ClientsideFuncType, *args, **kwargs):
"""Create a callback that updates the output by calling a clientside
(JavaScript) function instead of a Python function. See
`Dash.clientside_callback` for the full documentation, including
flexible signatures (grouped dependencies) and the `running=`,
`on_error=`, `hidden=`, `optional=` and `prevent_initial_call=`
keyword arguments.
"""
return register_clientside_callback(
GLOBAL_CALLBACK_LIST,
GLOBAL_CALLBACK_MAP,
Expand Down Expand Up @@ -683,15 +701,8 @@ def register_callback(

background = _kwargs.get("background")
manager = _kwargs.get("manager")
running = _kwargs.get("running")
running = _normalize_running(_kwargs.get("running"))
on_error = _kwargs.get("on_error")
if running is not None:
if not isinstance(running[0], (list, tuple)):
running = [running]
running = {
"running": {str(r[0]): r[1] for r in running},
"runningOff": {str(r[0]): r[2] for r in running},
}
allow_dynamic_callbacks = _kwargs.get("_allow_dynamic_callbacks")

output_indices = make_grouping_by_index(output, list(range(grouping_len(output))))
Expand Down Expand Up @@ -910,31 +921,7 @@ async def async_add_context(*args, **kwargs):
"""


def register_clientside_callback(
callback_list,
callback_map,
config_prevent_initial_callbacks,
inline_scripts,
clientside_function: ClientsideFuncType,
*args,
**kwargs,
):
output, inputs, state, prevent_initial_call = handle_callback_args(args, kwargs)
no_output = isinstance(output, (list,)) and len(output) == 0
insert_callback(
callback_list,
callback_map,
config_prevent_initial_callbacks,
output,
None,
inputs,
state,
None,
prevent_initial_call,
no_output=no_output,
hidden=kwargs.get("hidden", None),
)

def _resolve_clientside_function(clientside_function, inline_scripts):
# If JS source is explicitly given, create a namespace and function
# name, then inject the code.
if isinstance(clientside_function, str):
Expand All @@ -955,7 +942,78 @@ def register_clientside_callback(
namespace = clientside_function.namespace
function_name = clientside_function.function_name

return namespace, function_name


def register_clientside_callback(
callback_list,
callback_map,
config_prevent_initial_callbacks,
inline_scripts,
clientside_function: ClientsideFuncType,
*args,
**kwargs,
):
_validate.validate_clientside_callback_kwargs(kwargs)
on_error = kwargs.get("on_error")
if on_error is not None:
_validate.validate_clientside_on_error(on_error, ClientsideFunction)

(
output,
flat_inputs,
flat_state,
inputs_state_indices,
prevent_initial_call,
) = handle_grouped_callback_args(args, kwargs)
if isinstance(output, Output):
# Callback with a scalar (non-multi) Output
insert_output = output
has_output = True
else:
# Callback with multi or grouped Output
insert_output = flatten_grouping(output)
has_output = len(output) > 0

output_indices = make_grouping_by_index(output, list(range(grouping_len(output))))
insert_callback(
callback_list,
callback_map,
config_prevent_initial_callbacks,
insert_output,
output_indices,
flat_inputs,
flat_state,
inputs_state_indices,
prevent_initial_call,
running=_normalize_running(kwargs.get("running")),
no_output=not has_output,
optional=kwargs.get("optional", False),
hidden=kwargs.get("hidden", None),
)

namespace, function_name = _resolve_clientside_function(
clientside_function, inline_scripts
)
callback_list[-1]["clientside_function"] = {
"namespace": namespace,
"function_name": function_name,
}

if on_error is not None:
error_namespace, error_function_name = _resolve_clientside_function(
on_error, inline_scripts
)
callback_list[-1]["clientside_on_error"] = {
"namespace": error_namespace,
"function_name": error_function_name,
}

# Only serialize the argument/output groupings when they carry structure
# beyond a flat signature, so flat callbacks keep their existing spec and
# renderer code path.
if is_nontrivial_grouping(output_indices) or is_nontrivial_grouping(
inputs_state_indices
):
callback_list[-1]["outputs_indices"] = output_indices
callback_list[-1]["inputs_state_indices"] = inputs_state_indices
11 changes: 11 additions & 0 deletions dash/_grouping.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,17 @@ def validate_grouping(grouping, schema, full_schema=None, path=()):
pass


def is_nontrivial_grouping(grouping):
"""
True if a grouping is anything other than a scalar or a flat list —
i.e. a dict at the top level, or a list/tuple with nested structure.
"""
return isinstance(grouping, dict) or (
isinstance(grouping, (tuple, list))
and any(isinstance(g, (tuple, list, dict)) for g in grouping)
)


def update_args_group(g, triggered):
if isinstance(g, dict):
str_id = stringify_id(g["id"])
Expand Down
65 changes: 65 additions & 0 deletions dash/_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,71 @@ def validate_callback(outputs, inputs, state, extra_args, types):
validate_callback_arg(arg)


CLIENTSIDE_CALLBACK_KWARGS = {
"output",
"inputs",
"state",
"prevent_initial_call",
"hidden",
"optional",
"running",
"on_error",
}

SERVERSIDE_ONLY_CALLBACK_KWARGS = {
"background",
"interval",
"progress",
"progress_default",
"cancel",
"manager",
"cache_args_to_ignore",
"cache_ignore_triggered",
"api_endpoint",
"websocket",
"persistent",
"mcp_enabled",
"mcp_expose_docstring",
}


def validate_clientside_callback_kwargs(kwargs):
invalid = set(kwargs) - CLIENTSIDE_CALLBACK_KWARGS
if not invalid:
return

messages = []
server_only = sorted(invalid & SERVERSIDE_ONLY_CALLBACK_KWARGS)
unknown = sorted(invalid - SERVERSIDE_ONLY_CALLBACK_KWARGS)
if server_only:
messages.append(
f"{', '.join(f'`{k}`' for k in server_only)}: "
"only supported by server-side callbacks, not clientside_callback."
)
if unknown:
messages.append(
f"{', '.join(f'`{k}`' for k in unknown)}: "
"unexpected keyword argument(s)."
)
raise exceptions.CallbackException(
"Invalid keyword arguments passed to clientside_callback:\n"
+ "\n".join(f" {m}" for m in messages)
+ "\nSupported keyword arguments are: "
+ ", ".join(sorted(CLIENTSIDE_CALLBACK_KWARGS))
)


def validate_clientside_on_error(on_error, clientside_function_type):
if not isinstance(on_error, (str, clientside_function_type)):
raise exceptions.CallbackException(
"The `on_error` argument of clientside_callback must be a "
"JavaScript function, provided either as a source string or a "
f"ClientsideFunction, not {type(on_error).__name__}. "
"Python error handlers cannot run in the browser; the app-level "
"`on_error` does not apply to clientside callbacks."
)


def validate_callback_arg(arg):
if not isinstance(getattr(arg, "component_property", None), str):
raise exceptions.IncorrectTypeException(
Expand Down
Loading
Loading