From 3c693127d7a776eb7597fdbfbea3ed7833cbce01 Mon Sep 17 00:00:00 2001 From: philippe Date: Mon, 13 Jul 2026 09:19:24 -0400 Subject: [PATCH] Close feature gaps between clientside_callback and regular callbacks Add support to clientside_callback for features that previously only worked with server-side @callback: - Flexible/grouped signatures: output=/inputs=/state= may be dicts or nested lists. Dict groupings pass a single destructurable object to the JS function; the return value is validated against the output grouping. Grouping indices are serialized into the callback spec only when non-trivial, so flat signatures keep identical specs. - running=: on/off side updates applied around execution, reverting on success, PreventUpdate, and error. - on_error=: JS error handler (source string or ClientsideFunction) whose return value becomes the outputs (undefined -> no_update). - callback_context parity: triggered_prop_ids, args_grouping, outputs_grouping, using_args_grouping, using_outputs_grouping. - optional= passthrough. clientside_callback now raises CallbackException for unsupported kwargs (background=, progress=, cancel=, manager=, ...) instead of silently ignoring them. --- CHANGELOG.md | 12 + dash/_callback.py | 126 +++++--- dash/_grouping.py | 11 + dash/_validate.py | 65 +++++ dash/dash-renderer/src/actions/callbacks.ts | 204 ++++++++++--- dash/dash-renderer/src/types/callbacks.ts | 11 + dash/dash-renderer/src/utils/grouping.ts | 115 ++++++++ dash/dash-renderer/tests/grouping.test.js | 103 +++++++ dash/dash.py | 48 +++ .../integration/clientside/test_clientside.py | 271 +++++++++++++++++ .../clientside/test_clientside_grouped.py | 276 ++++++++++++++++++ .../unit/library/test_clientside_callback.py | 134 +++++++++ tests/unit/library/test_grouped_callbacks.py | 88 ++++-- 13 files changed, 1369 insertions(+), 95 deletions(-) create mode 100644 dash/dash-renderer/src/utils/grouping.ts create mode 100644 dash/dash-renderer/tests/grouping.test.js create mode 100644 tests/integration/clientside/test_clientside_grouped.py create mode 100644 tests/unit/library/test_clientside_callback.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e48552db5f..706ab87034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/dash/_callback.py b/dash/_callback.py index 81a5345830..3c7b8e4d74 100644 --- a/dash/_callback.py +++ b/dash/_callback.py @@ -8,7 +8,6 @@ from typing_extensions import ParamSpec from .dependencies import ( - handle_callback_args, handle_grouped_callback_args, Output, ClientsideFunction, @@ -28,6 +27,7 @@ flatten_grouping, make_grouping_by_index, grouping_len, + is_nontrivial_grouping, ) from ._utils import ( create_callback_id, @@ -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, @@ -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)))) @@ -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): @@ -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 diff --git a/dash/_grouping.py b/dash/_grouping.py index 7271d973bb..33b806f0fa 100644 --- a/dash/_grouping.py +++ b/dash/_grouping.py @@ -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"]) diff --git a/dash/_validate.py b/dash/_validate.py index b80c61df2c..ee7431ab19 100644 --- a/dash/_validate.py +++ b/dash/_validate.py @@ -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( diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index e6604a2337..47a66b7465 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -23,6 +23,7 @@ import { import { CallbackResult, ICallback, + ICallbackDefinition, IExecutedCallback, IExecutingCallback, ICallbackPayload, @@ -32,7 +33,8 @@ import { BackgroundCallbackInfo, CallbackResponse, CallbackResponseData, - SideUpdateOutput + SideUpdateOutput, + GroupingIndices } from '../types/callbacks'; import {isMultiValued, stringifyId, isMultiOutputProp} from './dependencies'; import {urlBase} from './utils'; @@ -43,6 +45,7 @@ import {notifyObservers, updateProps} from './index'; import {CallbackJobPayload} from '../reducers/callbackJobs'; import {parsePatchProps} from './patch'; import {computePaths, getPath} from './paths'; +import {mapGrouping, flattenGroupingByIndex} from '../utils/grouping'; import {requestDependencies} from './requestDependencies'; @@ -246,12 +249,21 @@ function cleanOutputProp(property: string) { return property.split('@')[0]; } +const isRangeIndices = (indices: GroupingIndices) => + Array.isArray(indices) && indices.every((ind, i) => ind === i); + async function handleClientside( dispatch: any, - clientside_function: any, + callbackDefinition: ICallbackDefinition, config: any, payload: ICallbackPayload ) { + const { + clientside_function, + clientside_on_error, + outputs_indices, + inputs_state_indices + } = callbackDefinition; const dc = ((window as any).dash_clientside = (window as any).dash_clientside || {}); if (!dc.no_update) { @@ -274,27 +286,116 @@ async function handleClientside( const result: any = {}; let status: any = STATUS.OK; - try { - const {namespace, function_name} = clientside_function; - let args = inputs.map(getVals); - if (state) { - args = concat(args, state.map(getVals)); + const assignLeaf = (outij: any, retij: any) => { + const {id, property} = outij; + const idStr = stringifyId(id); + const dataForId = (result[idStr] = result[idStr] || {}); + if (retij !== dc.no_update) { + dataForId[cleanOutputProp(property)] = retij; + } + }; + + const assignResult = (returnValue: any) => { + if (!outputs) { + return; + } + if (outputs_indices !== undefined) { + const flatOutputs = Array.isArray(outputs) ? outputs : [outputs]; + const flatReturn = + returnValue === dc.no_update + ? new Array(flatOutputs.length).fill(dc.no_update) + : flattenGroupingByIndex( + outputs_indices, + returnValue, + flatOutputs.length + ); + zip(flatOutputs, flatReturn).forEach(([outi, reti]) => { + zipIfArray(outi, reti).forEach(([outij, retij]) => + assignLeaf(outij, retij) + ); + }); + } else { + zipIfArray(outputs, returnValue).forEach(([outi, reti]) => { + zipIfArray(outi, reti).forEach(([outij, retij]) => + assignLeaf(outij, retij) + ); + }); } + }; + + // setup callback context, mirroring the server-side CallbackContext + // fields that have a clientside equivalent + const annotateEntry = (entry: any) => { + const strId = stringifyId(entry.id); + return { + ...entry, + value: entry.value, + str_id: strId, + triggered: payload.changedPropIds.includes( + `${strId}.${entry.property}` + ), + id: entry.id + }; + }; + const annotate = (entry: any) => + Array.isArray(entry) ? entry.map(annotateEntry) : annotateEntry(entry); + + const flatDeps = concat(inputs, state || []); + const annotatedDeps = flatDeps.map(annotate); + let argsGrouping: any = annotatedDeps; + let usingArgsGrouping = false; + if (inputs_state_indices !== undefined) { + argsGrouping = mapGrouping(i => annotatedDeps[i], inputs_state_indices); + usingArgsGrouping = + typeof inputs_state_indices !== 'number' && + !isRangeIndices(inputs_state_indices); + } - // setup callback context - dc.callback_context = {}; - dc.callback_context.triggered = payload.changedPropIds.map(prop_id => ({ + let outputsGrouping: any = outputs; + let usingOutputsGrouping = false; + if (outputs !== undefined && outputs_indices !== undefined) { + const flatOutputs = Array.isArray(outputs) ? outputs : [outputs]; + outputsGrouping = mapGrouping(i => flatOutputs[i], outputs_indices); + usingOutputsGrouping = + typeof outputs_indices !== 'number' && + !isRangeIndices(outputs_indices); + } + + const ctx: any = { + triggered: payload.changedPropIds.map(prop_id => ({ prop_id: prop_id, value: inputDict[prop_id] - })); - dc.callback_context.triggered_id = getTriggeredId( - payload.changedPropIds - ); - dc.callback_context.inputs_list = inputs; - dc.callback_context.inputs = inputDict; - dc.callback_context.states_list = state; - dc.callback_context.states = stateDict; - dc.callback_context.outputs_list = outputs; + })), + triggered_id: getTriggeredId(payload.changedPropIds), + triggered_prop_ids: getTriggeredPropIds(payload.changedPropIds), + inputs_list: inputs, + inputs: inputDict, + states_list: state, + states: stateDict, + outputs_list: outputs, + args_grouping: argsGrouping, + using_args_grouping: usingArgsGrouping, + outputs_grouping: outputsGrouping, + using_outputs_grouping: usingOutputsGrouping + }; + + try { + const {namespace, function_name} = clientside_function as any; + let args: any[]; + if (inputs_state_indices !== undefined) { + const grouped = mapGrouping( + i => getVals(flatDeps[i]), + inputs_state_indices + ); + // A list grouping provides one positional argument per top-level + // item; dict and scalar groupings provide a single argument (the + // clientside analog of keyword arguments). + args = Array.isArray(inputs_state_indices) ? grouped : [grouped]; + } else { + args = flatDeps.map(getVals); + } + + dc.callback_context = ctx; let returnValue = dc[namespace][function_name](...args); @@ -304,21 +405,34 @@ async function handleClientside( returnValue = await returnValue; } - if (outputs) { - zipIfArray(outputs, returnValue).forEach(([outi, reti]) => { - zipIfArray(outi, reti).forEach(([outij, retij]) => { - const {id, property} = outij; - const idStr = stringifyId(id); - const dataForId = (result[idStr] = result[idStr] || {}); - if (retij !== dc.no_update) { - dataForId[cleanOutputProp(property)] = retij; - } - }); - }); - } + assignResult(returnValue); } catch (e) { if (e === dc.PreventUpdate) { status = STATUS.PREVENT_UPDATE; + } else if (clientside_on_error) { + // Mirror the server-side per-callback on_error: the handler + // receives the error; its return value is used as the callback's + // outputs, `undefined` meaning no_update for all outputs. Errors + // thrown by the handler itself (or a PreventUpdate) propagate. + try { + dc.callback_context = ctx; + const {namespace, function_name} = clientside_on_error; + let handled = dc[namespace][function_name](e); + delete dc.callback_context; + if (typeof handled?.then === 'function') { + handled = await handled; + } + if (handled !== undefined) { + assignResult(handled); + } + } catch (handlerError) { + if (handlerError === dc.PreventUpdate) { + status = STATUS.PREVENT_UPDATE; + } else { + status = STATUS.CLIENTSIDE_ERROR; + throw handlerError; + } + } } else { status = STATUS.CLIENTSIDE_ERROR; throw e; @@ -853,6 +967,20 @@ function inputsToDict(inputs_list: any) { return inputs; } +function getTriggeredPropIds(triggered: string[]): {[key: string]: any} { + // Mirrors the server-side callback_context.triggered_prop_ids: a dict + // mapping each triggered prop_id, e.g. "btn.n_clicks", to the component + // id, e.g. "btn" — parsed to an object for pattern-matching ids. + const triggeredPropIds: {[key: string]: any} = {}; + (triggered || []).forEach(propId => { + const componentId = propId.substring(0, propId.lastIndexOf('.')); + triggeredPropIds[propId] = componentId.startsWith('{') + ? JSON.parse(componentId) + : componentId; + }); + return triggeredPropIds; +} + function getTriggeredId(triggered: string[]): string | object | undefined { // for regular callbacks, takes the first triggered prop_id, e.g. "btn.n_clicks" and returns "btn" // for pattern matching callback, e.g. '{"index":0, "type":"btn"}' and returns {index:0, type: "btn"}' @@ -886,7 +1014,8 @@ export function executeCallback( state, clientside_function, background, - dynamic_creator + dynamic_creator, + running } = cb.callback; try { const inVals = fillVals(paths, layout, cb, inputs, 'Input', true); @@ -956,10 +1085,15 @@ export function executeCallback( }; if (clientside_function) { + let runningOff: any; + if (running) { + dispatch(sideUpdate(running.running, payload)); + runningOff = running.runningOff; + } try { let data = await handleClientside( dispatch, - clientside_function, + cb.callback, config, payload ); @@ -991,6 +1125,10 @@ export function executeCallback( return {data, payload}; } catch (error: any) { return {error, payload}; + } finally { + if (runningOff) { + dispatch(sideUpdate(runningOff, payload)); + } } } diff --git a/dash/dash-renderer/src/types/callbacks.ts b/dash/dash-renderer/src/types/callbacks.ts index 5f963463d2..9abbf1a9ac 100644 --- a/dash/dash-renderer/src/types/callbacks.ts +++ b/dash/dash-renderer/src/types/callbacks.ts @@ -1,10 +1,21 @@ type CallbackId = string | {[key: string]: any}; +export type GroupingIndices = + | number + | GroupingIndices[] + | {[key: string]: GroupingIndices}; + export interface ICallbackDefinition { clientside_function?: { namespace: string; function_name: string; }; + clientside_on_error?: { + namespace: string; + function_name: string; + }; + outputs_indices?: GroupingIndices; + inputs_state_indices?: GroupingIndices; input: string; inputs: ICallbackProperty[]; output: string; diff --git a/dash/dash-renderer/src/utils/grouping.ts b/dash/dash-renderer/src/utils/grouping.ts new file mode 100644 index 0000000000..d19b8b19fd --- /dev/null +++ b/dash/dash-renderer/src/utils/grouping.ts @@ -0,0 +1,115 @@ +import {GroupingIndices} from '../types/callbacks'; + +/** + * Grouping utilities mirroring dash/_grouping.py, operating on the + * index-groupings serialized into the callback spec (`outputs_indices`, + * `inputs_state_indices`): structures of plain numbers, arrays and objects + * whose leaves are indices into a flat dependency list. + */ + +/** + * Build a value grouping with the same shape as `grouping`, replacing each + * leaf index i with fn(i). + */ +export function mapGrouping( + fn: (index: number) => any, + grouping: GroupingIndices +): any { + if (Array.isArray(grouping)) { + return grouping.map(g => mapGrouping(fn, g)); + } + if (typeof grouping === 'object' && grouping !== null) { + const mapped: {[key: string]: any} = {}; + for (const key of Object.keys(grouping)) { + mapped[key] = mapGrouping(fn, grouping[key]); + } + return mapped; + } + return fn(grouping); +} + +function schemaPathError( + schema: GroupingIndices, + path: (string | number)[], + detail: string +): Error { + return new Error( + 'Callback return value does not match the declared output grouping.\n' + + `Path: ${JSON.stringify(path)}\n` + + `Expected shape: ${JSON.stringify(schema)}\n` + + detail + ); +} + +/** + * Inverse of mapGrouping: walk `grouping` and `value` together, placing each + * leaf of `value` into a flat array at the leaf's index. Validates that + * `value` matches the shape of `grouping` (mirrors validate_grouping in + * dash/_grouping.py) and throws a descriptive Error on mismatch. Leaves of + * the schema are numbers, so leaf values that are themselves arrays or + * objects (pattern-matching ALL values, no_update sentinels, dict props) are + * unambiguous. + */ +export function flattenGroupingByIndex( + grouping: GroupingIndices, + value: any, + flatLength: number, + path: (string | number)[] = [] +): any[] { + const flat: any[] = new Array(flatLength); + const fill = ( + schema: GroupingIndices, + val: any, + currentPath: (string | number)[] + ) => { + if (Array.isArray(schema)) { + if (!Array.isArray(val)) { + throw schemaPathError( + schema, + currentPath, + `Expected an array, received: ${JSON.stringify(val)}` + ); + } + if (val.length !== schema.length) { + throw schemaPathError( + schema, + currentPath, + `Expected an array of length ${schema.length}, ` + + `received one of length ${val.length}` + ); + } + schema.forEach((s, i) => fill(s, val[i], currentPath.concat(i))); + } else if (typeof schema === 'object' && schema !== null) { + const expectedKeys = Object.keys(schema); + if (typeof val !== 'object' || val === null || Array.isArray(val)) { + throw schemaPathError( + schema, + currentPath, + `Expected an object with keys ${JSON.stringify( + expectedKeys + )}, received: ${JSON.stringify(val)}` + ); + } + const receivedKeys = Object.keys(val); + if ( + expectedKeys.length !== receivedKeys.length || + expectedKeys.some(k => !(k in val)) + ) { + throw schemaPathError( + schema, + currentPath, + `Expected an object with keys ${JSON.stringify( + expectedKeys + )}, received keys ${JSON.stringify(receivedKeys)}` + ); + } + expectedKeys.forEach(k => + fill(schema[k], val[k], currentPath.concat(k)) + ); + } else { + flat[schema] = val; + } + }; + fill(grouping, value, path); + return flat; +} diff --git a/dash/dash-renderer/tests/grouping.test.js b/dash/dash-renderer/tests/grouping.test.js new file mode 100644 index 0000000000..dac31b3f1c --- /dev/null +++ b/dash/dash-renderer/tests/grouping.test.js @@ -0,0 +1,103 @@ +import {expect} from 'chai'; +import {describe, it} from 'mocha'; +import {mapGrouping, flattenGroupingByIndex} from '../src/utils/grouping'; + +describe('grouping utils (clientside flexible signatures)', () => { + describe('mapGrouping', () => { + it('maps a scalar grouping', () => { + expect(mapGrouping(i => i * 10, 2)).to.equal(20); + }); + + it('maps a flat list grouping', () => { + expect(mapGrouping(i => i * 10, [0, 1, 2])).to.deep.equal([ + 0, 10, 20 + ]); + }); + + it('maps a dict grouping', () => { + expect(mapGrouping(i => i * 10, {a: 0, b: 1})).to.deep.equal({ + a: 0, + b: 10 + }); + }); + + it('maps a nested mixed grouping', () => { + expect( + mapGrouping(i => i * 10, {a: [0, 1], b: {c: 2}}) + ).to.deep.equal({a: [0, 10], b: {c: 20}}); + }); + }); + + describe('flattenGroupingByIndex', () => { + it('round-trips with mapGrouping', () => { + const indices = {a: [0, 2], b: {c: 1}}; + const flat = ['x', 'y', 'z']; + const grouped = mapGrouping(i => flat[i], indices); + expect(flattenGroupingByIndex(indices, grouped, 3)).to.deep.equal( + flat + ); + }); + + it('flattens a scalar grouping', () => { + expect(flattenGroupingByIndex(0, 'val', 1)).to.deep.equal(['val']); + }); + + it('flattens a dict grouping with reordered indices', () => { + // Mixed Input/State: {a: Input, s: State, b: Input} -> {a: 0, s: 2, b: 1} + expect( + flattenGroupingByIndex( + {a: 0, s: 2, b: 1}, + {a: 'A', s: 'S', b: 'B'}, + 3 + ) + ).to.deep.equal(['A', 'B', 'S']); + }); + + it('preserves array and object leaf values', () => { + const noUpdate = {description: 'no_update sentinel'}; + const flat = flattenGroupingByIndex( + {a: 0, b: 1}, + {a: [1, 2, 3], b: noUpdate}, + 2 + ); + expect(flat[0]).to.deep.equal([1, 2, 3]); + expect(flat[1]).to.equal(noUpdate); + }); + + it('throws on wrong array length', () => { + expect(() => + flattenGroupingByIndex([0, 1], ['only-one'], 2) + ).to.throw(/length 2/); + }); + + it('throws on non-array value for array schema', () => { + expect(() => flattenGroupingByIndex([0, 1], 'nope', 2)).to.throw( + /Expected an array/ + ); + }); + + it('throws on missing dict keys', () => { + expect(() => + flattenGroupingByIndex({a: 0, b: 1}, {a: 'A'}, 2) + ).to.throw(/keys/); + }); + + it('throws on extra dict keys', () => { + expect(() => + flattenGroupingByIndex({a: 0}, {a: 'A', b: 'B'}, 1) + ).to.throw(/keys/); + }); + + it('throws on non-object value for dict schema', () => { + expect(() => flattenGroupingByIndex({a: 0}, ['A'], 1)).to.throw( + /Expected an object/ + ); + }); + + it('reports the path of a nested mismatch', () => { + expect(() => + flattenGroupingByIndex({a: [0, 1]}, {a: [1]}, 2) + ).to.throw(/\["a"\]/); + }); + }); +}); diff --git a/dash/dash.py b/dash/dash.py index 36a12c6d73..485dd6296b 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -1477,6 +1477,54 @@ def clientside_callback(self, clientside_function, *args, **kwargs): The last, optional argument `prevent_initial_call` causes the callback not to fire when its outputs are first added to the page. Defaults to `False` unless `prevent_initial_callbacks=True` at the app level. + + Like `@app.callback`, dependencies may be given as flexible signatures + with the `output=`, `inputs=` and `state=` keyword arguments, + using dicts or nested lists/tuples of dependencies. A dict grouping is + passed to the JavaScript function as a single object argument that can + be destructured (the clientside analog of keyword arguments), and the + function returns an object with the same keys as the output grouping: + ``` + app.clientside_callback( + ''' + function({a, b}) { + return {sum: a + b, product: a * b}; + } + ''', + output=dict( + sum=Output('sum', 'children'), + product=Output('product', 'children'), + ), + inputs=dict( + a=Input('a', 'value'), + b=Input('b', 'value'), + ), + ) + ``` + A list/tuple grouping provides one function argument per top-level + item, each of which may itself be a nested grouping. + + Other supported keyword arguments: + + - `running`: a list of `(Output, on_value, off_value)` tuples applied + when the callback starts and reverted when it finishes (most useful + for async/promise-returning clientside functions). + - `on_error`: a JavaScript error handler, given as a source string or + a `ClientsideFunction`. It is called with the error when the + callback throws (other than `dash_clientside.PreventUpdate`); its + return value is used as the callback's outputs, with `undefined` + leaving all outputs unchanged. The app-level (Python) `on_error` + does not apply to clientside callbacks. + - `hidden`: hide the callback from the devtools UI. + - `optional`: mark all dependencies as optional on layout checks. + + Inside the function, `window.dash_clientside.callback_context` + provides `triggered`, `triggered_id`, `triggered_prop_ids`, `inputs`, + `inputs_list`, `states`, `states_list`, `outputs_list`, + `args_grouping`, `outputs_grouping`, `using_args_grouping` and + `using_outputs_grouping`. Request-bound fields of the server-side + `callback_context` (such as `cookies`, `headers` or `path`) have no + clientside equivalent. """ return _callback.register_clientside_callback( self._callback_list, diff --git a/tests/integration/clientside/test_clientside.py b/tests/integration/clientside/test_clientside.py index fe627e27ad..6e38ecf181 100644 --- a/tests/integration/clientside/test_clientside.py +++ b/tests/integration/clientside/test_clientside.py @@ -937,3 +937,274 @@ def test_clsd022_clientside_pattern_matching_dots(dash_duo): dash_duo.wait_for_text_to_equal(".output", "clicked 1") assert dash_duo.get_logs() == [] + + +def test_clsd023_clientside_running(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Button("start", id="start"), + html.Button("other", id="other-btn"), + html.Div(id="output-div"), + ] + ) + + app.clientside_callback( + """ + async function(n_clicks) { + await new Promise(resolve => setTimeout(resolve, 500)); + return `done ${n_clicks}`; + } + """, + Output("output-div", "children"), + Input("start", "n_clicks"), + running=[(Output("other-btn", "disabled"), True, False)], + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + assert dash_duo.find_element("#other-btn").get_attribute("disabled") is None + + dash_duo.find_element("#start").click() + dash_duo.wait_for_element("#other-btn:disabled") + dash_duo.wait_for_text_to_equal("#output-div", "done 1") + dash_duo.wait_for_element("#other-btn:not(:disabled)") + + assert dash_duo.get_logs() == [] + + +def test_clsd024_clientside_on_error_handled(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="input", value="fine"), + html.Div(id="output-div"), + ] + ) + + app.clientside_callback( + """ + function(value) { + if (value === "boom") { + throw new Error("something went wrong"); + } + return `ok: ${value}`; + } + """, + Output("output-div", "children"), + Input("input", "value"), + on_error="function(err) { return `handled: ${err.message}`; }", + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#output-div", "ok: fine") + + input_ = dash_duo.find_element("#input") + dash_duo.clear_input(input_) + input_.send_keys("boom") + dash_duo.wait_for_text_to_equal("#output-div", "handled: something went wrong") + + # handled errors don't reach the devtools/console + assert dash_duo.get_logs() == [] + + +def test_clsd025_clientside_on_error_no_return(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Button("ok", id="ok"), + html.Button("boom", id="boom"), + html.Div(id="output-div"), + html.Div(id="error-marker"), + ] + ) + + app.clientside_callback( + """ + function(ok, boom) { + const trig = window.dash_clientside.callback_context.triggered_id; + if (trig === "boom") { + throw new Error(`boom ${boom}`); + } + return `ok ${ok}`; + } + """, + Output("output-div", "children"), + Input("ok", "n_clicks"), + Input("boom", "n_clicks"), + # An on_error not returning anything leaves all outputs untouched, + # like a server-side error handler returning None. + on_error=""" + function(err) { + window.dash_clientside.set_props("error-marker", {children: err.message}); + } + """, + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + dash_duo.find_element("#ok").click() + dash_duo.wait_for_text_to_equal("#output-div", "ok 1") + + dash_duo.find_element("#boom").click() + dash_duo.wait_for_text_to_equal("#error-marker", "boom 1") + # output kept its last good value + dash_duo.wait_for_text_to_equal("#output-div", "ok 1") + + assert dash_duo.get_logs() == [] + + +def test_clsd026_clientside_on_error_async(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="input", value="start"), + html.Div(id="output-div"), + ] + ) + + app.clientside_callback( + """ + async function(value) { + throw new Error(`rejected ${value}`); + } + """, + Output("output-div", "children"), + Input("input", "value"), + on_error=""" + async function(err) { + await new Promise(resolve => setTimeout(resolve, 10)); + return `async handled: ${err.message}`; + } + """, + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#output-div", "async handled: rejected start") + + assert dash_duo.get_logs() == [] + + +def test_clsd027_clientside_running_with_error(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Button("start", id="start"), + html.Button("other", id="other-btn"), + html.Div(id="output-div"), + ] + ) + + app.clientside_callback( + """ + async function(n_clicks) { + await new Promise(resolve => setTimeout(resolve, 300)); + throw new Error("failed"); + } + """, + Output("output-div", "children"), + Input("start", "n_clicks"), + running=[(Output("other-btn", "disabled"), True, False)], + on_error="function(err) { return `caught: ${err.message}`; }", + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + dash_duo.find_element("#start").click() + dash_duo.wait_for_element("#other-btn:disabled") + dash_duo.wait_for_text_to_equal("#output-div", "caught: failed") + # runningOff is applied even when the callback errored + dash_duo.wait_for_element("#other-btn:not(:disabled)") + + assert dash_duo.get_logs() == [] + + +def test_clsd028_clientside_triggered_prop_ids(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Button("btn0", id="btn0"), + html.Button("btn1:0", id={"btn1": 0}), + html.Div(id="output-div"), + ] + ) + + app.clientside_callback( + """ + function(n0, n1) { + const ids = window.dash_clientside.callback_context.triggered_prop_ids; + return JSON.stringify(ids); + } + """, + Output("output-div", "children"), + Input("btn0", "n_clicks"), + Input({"btn1": ALL}, "n_clicks"), + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + dash_duo.find_element("#btn0").click() + dash_duo.wait_for_text_to_equal("#output-div", '{"btn0.n_clicks":"btn0"}') + + dash_duo.find_element("button[id*='btn1\":0']").click() + dash_duo.wait_for_text_to_equal( + "#output-div", '{"{\\"btn1\\":0}.n_clicks":{"btn1":0}}' + ) + + assert dash_duo.get_logs() == [] + + +def test_clsd029_clientside_args_grouping_positional(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Button("go", id="go"), + dcc.Input(id="other", value="v0"), + html.Div(id="output-div"), + ] + ) + + app.clientside_callback( + """ + function(n_clicks, value) { + const ctx = window.dash_clientside.callback_context; + const g = ctx.args_grouping; + return JSON.stringify({ + using_args: ctx.using_args_grouping, + using_outputs: ctx.using_outputs_grouping, + first_triggered: g[0].triggered, + second_triggered: g[1].triggered, + second_str_id: g[1].str_id, + second_value: g[1].value + }); + } + """, + Output("output-div", "children"), + Input("go", "n_clicks"), + State("other", "value"), + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + dash_duo.find_element("#go").click() + dash_duo.wait_for_text_to_equal( + "#output-div", + '{"using_args":false,"using_outputs":false,"first_triggered":true,' + '"second_triggered":false,"second_str_id":"other","second_value":"v0"}', + ) + + assert dash_duo.get_logs() == [] diff --git a/tests/integration/clientside/test_clientside_grouped.py b/tests/integration/clientside/test_clientside_grouped.py new file mode 100644 index 0000000000..78fea5621d --- /dev/null +++ b/tests/integration/clientside/test_clientside_grouped.py @@ -0,0 +1,276 @@ +# -*- coding: UTF-8 -*- +from dash import Dash, Input, Output, State, html, dcc +from dash.dependencies import ALL + + +def test_clsg001_grouped_dict_inputs_outputs(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="first", value="one"), + dcc.Input(id="second", value="two"), + html.Div(id="out-a"), + html.Div(id="out-b"), + ] + ) + + app.clientside_callback( + """ + function({a, b}) { + return {x: `a=${a}`, y: `b=${b}`}; + } + """, + output=dict(x=Output("out-a", "children"), y=Output("out-b", "children")), + inputs=dict(a=Input("first", "value"), b=Input("second", "value")), + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#out-a", "a=one") + dash_duo.wait_for_text_to_equal("#out-b", "b=two") + + dash_duo.find_element("#first").send_keys("!") + dash_duo.wait_for_text_to_equal("#out-a", "a=one!") + dash_duo.wait_for_text_to_equal("#out-b", "b=two") + + assert dash_duo.get_logs() == [] + + +def test_clsg002_grouped_nested_list_positional_args(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="x", value="1"), + dcc.Input(id="y", value="2"), + dcc.Input(id="z", value="3"), + html.Div(id="out"), + ] + ) + + app.clientside_callback( + """ + function(pair, third) { + return `${pair[0]}-${pair[1]}-${third}`; + } + """, + output=Output("out", "children"), + inputs=[[Input("x", "value"), Input("y", "value")], Input("z", "value")], + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#out", "1-2-3") + + dash_duo.find_element("#y").send_keys("0") + dash_duo.wait_for_text_to_equal("#out", "1-20-3") + + assert dash_duo.get_logs() == [] + + +def test_clsg003_grouped_mixed_input_state(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="in-a", value="a0"), + dcc.Input(id="state-s", value="s0"), + dcc.Input(id="in-b", value="b0"), + html.Div(id="out"), + ] + ) + + # Dict mixing Input and State: index remapping must put each value under + # the right name regardless of Input/State declaration order. + app.clientside_callback( + """ + function({a, s, b}) { + return `a=${a} s=${s} b=${b}`; + } + """, + output=Output("out", "children"), + inputs=dict( + a=Input("in-a", "value"), + s=State("state-s", "value"), + b=Input("in-b", "value"), + ), + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#out", "a=a0 s=s0 b=b0") + + # Changing state alone must not fire; changing an input picks up new state + dash_duo.find_element("#state-s").send_keys("!") + dash_duo.find_element("#in-b").send_keys("!") + dash_duo.wait_for_text_to_equal("#out", "a=a0 s=s0! b=b0!") + + assert dash_duo.get_logs() == [] + + +def test_clsg004_grouped_no_update(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Button("update both", id="both"), + html.Button("skip second", id="skip-b"), + html.Button("skip all", id="skip-all"), + html.Div(id="out-a", children="init-a"), + html.Div(id="out-b", children="init-b"), + ] + ) + + app.clientside_callback( + """ + function({both, skipb, skipall}) { + const no_update = window.dash_clientside.no_update; + const trig = window.dash_clientside.callback_context.triggered_id; + if (trig === "skip-all") { + return no_update; + } + if (trig === "skip-b") { + return {x: `skipb-${skipb}`, y: no_update}; + } + return {x: `both-${both}`, y: `both-${both}`}; + } + """, + output=dict(x=Output("out-a", "children"), y=Output("out-b", "children")), + inputs=dict( + both=Input("both", "n_clicks"), + skipb=Input("skip-b", "n_clicks"), + skipall=Input("skip-all", "n_clicks"), + ), + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + dash_duo.find_element("#both").click() + dash_duo.wait_for_text_to_equal("#out-a", "both-1") + dash_duo.wait_for_text_to_equal("#out-b", "both-1") + + # per-leaf no_update: out-b keeps its previous value + dash_duo.find_element("#skip-b").click() + dash_duo.wait_for_text_to_equal("#out-a", "skipb-1") + dash_duo.wait_for_text_to_equal("#out-b", "both-1") + + # whole-return no_update: neither output changes; prove the callback ran + # by clicking "both" afterwards and seeing the next update come through + dash_duo.find_element("#skip-all").click() + dash_duo.find_element("#both").click() + dash_duo.wait_for_text_to_equal("#out-a", "both-2") + dash_duo.wait_for_text_to_equal("#out-b", "both-2") + + assert dash_duo.get_logs() == [] + + +def test_clsg005_grouped_wildcard_all_leaf(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id={"type": "many", "idx": 0}, value="p"), + dcc.Input(id={"type": "many", "idx": 1}, value="q"), + dcc.Input(id="single", value="r"), + html.Div(id="out"), + ] + ) + + app.clientside_callback( + """ + function({many, single}) { + return `${many.join("+")}|${single}`; + } + """, + output=Output("out", "children"), + inputs=dict( + many=Input({"type": "many", "idx": ALL}, "value"), + single=Input("single", "value"), + ), + ) + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#out", "p+q|r") + + dash_duo.find_element("#single").send_keys("!") + dash_duo.wait_for_text_to_equal("#out", "p+q|r!") + + assert dash_duo.get_logs() == [] + + +def test_clsg006_grouped_args_grouping_context(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Button("go", id="go"), + dcc.Input(id="other", value="v0"), + html.Div(id="out"), + ] + ) + + app.clientside_callback( + """ + function({n, v}) { + const ctx = window.dash_clientside.callback_context; + const g = ctx.args_grouping; + return JSON.stringify({ + using_args: ctx.using_args_grouping, + using_outputs: ctx.using_outputs_grouping, + n_triggered: g.n.triggered, + v_triggered: g.v.triggered, + v_str_id: g.v.str_id, + v_value: g.v.value + }); + } + """, + output=Output("out", "children"), + inputs=dict(n=Input("go", "n_clicks"), v=State("other", "value")), + prevent_initial_call=True, + ) + + dash_duo.start_server(app) + + dash_duo.find_element("#go").click() + dash_duo.wait_for_text_to_equal( + "#out", + '{"using_args":true,"using_outputs":false,"n_triggered":true,' + '"v_triggered":false,"v_str_id":"other","v_value":"v0"}', + ) + + assert dash_duo.get_logs() == [] + + +def test_clsg007_grouped_wrong_return_shape(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + dcc.Input(id="val", value="start"), + html.Div(id="out-a", children="init-a"), + html.Div(id="out-b", children="init-b"), + ] + ) + + app.clientside_callback( + """ + function({v}) { + return {x: v}; // missing the `y` key + } + """, + output=dict(x=Output("out-a", "children"), y=Output("out-b", "children")), + inputs=dict(v=Input("val", "value")), + ) + + dash_duo.start_server(app) + + # The shape mismatch surfaces as a clientside error and no outputs update + dash_duo.wait_for_text_to_equal("#out-a", "init-a") + dash_duo.wait_for_text_to_equal("#out-b", "init-b") + + logs = dash_duo.get_logs() + assert logs + assert any("output grouping" in log["message"] for log in logs) diff --git a/tests/unit/library/test_clientside_callback.py b/tests/unit/library/test_clientside_callback.py new file mode 100644 index 0000000000..5655755c77 --- /dev/null +++ b/tests/unit/library/test_clientside_callback.py @@ -0,0 +1,134 @@ +import pytest + +import dash +from dash import ClientsideFunction, Input, Output, State +from dash.exceptions import CallbackException + + +def make_app(): + app = dash.Dash() + return app + + +def register(app, **kwargs): + return app.clientside_callback( + "function(value) { return value; }", + Output("out", "children"), + Input("in", "value"), + **kwargs, + ) + + +@pytest.mark.parametrize( + "kwarg", + [ + {"background": True}, + {"interval": 500}, + {"progress": Output("p", "value")}, + {"progress_default": 0}, + {"cancel": Input("c", "n_clicks")}, + {"manager": object()}, + {"cache_args_to_ignore": [0]}, + {"cache_ignore_triggered": False}, + {"api_endpoint": "/api"}, + {"websocket": True}, + {"persistent": True}, + {"mcp_enabled": True}, + {"mcp_expose_docstring": True}, + ], +) +def test_clientside_rejects_serverside_only_kwargs(kwarg): + app = make_app() + with pytest.raises(CallbackException) as err: + register(app, **kwarg) + assert "only supported by server-side callbacks" in str(err.value) + assert f"`{next(iter(kwarg))}`" in str(err.value) + + +def test_clientside_rejects_unknown_kwargs(): + app = make_app() + with pytest.raises(CallbackException) as err: + register(app, prevnt_initial_call=True) + assert "unexpected keyword argument" in str(err.value) + assert "`prevnt_initial_call`" in str(err.value) + + +def test_clientside_supported_kwargs_accepted(): + app = make_app() + register(app, prevent_initial_call=True, hidden=True, optional=True) + spec = app._callback_list[-1] + assert spec["prevent_initial_call"] is True + assert spec["hidden"] is True + assert spec["optional"] is True + + +def test_clientside_running_spec(): + app = make_app() + register(app, running=[(Output("btn", "disabled"), True, False)]) + assert app._callback_list[-1]["running"] == { + "running": {"btn.disabled": True}, + "runningOff": {"btn.disabled": False}, + } + + +def test_clientside_running_single_tuple_spec(): + app = make_app() + register(app, running=[Output("btn", "disabled"), True, False]) + assert app._callback_list[-1]["running"] == { + "running": {"btn.disabled": True}, + "runningOff": {"btn.disabled": False}, + } + + +def test_clientside_on_error_inline_string(): + app = make_app() + register(app, on_error="function(err) { return 'handled'; }") + on_error = app._callback_list[-1]["clientside_on_error"] + assert on_error["namespace"] == "_dashprivate_clientside_funcs" + # The handler source is injected as an inline script under its hash name + assert any(on_error["function_name"] in script for script in app._inline_scripts) + + +def test_clientside_on_error_clientside_function(): + app = make_app() + register(app, on_error=ClientsideFunction("ns", "handler")) + assert app._callback_list[-1]["clientside_on_error"] == { + "namespace": "ns", + "function_name": "handler", + } + + +def test_clientside_on_error_rejects_python_callable(): + app = make_app() + with pytest.raises(CallbackException) as err: + register(app, on_error=lambda e: None) + assert "JavaScript function" in str(err.value) + + +def test_clientside_no_on_error_key_by_default(): + app = make_app() + register(app) + assert "clientside_on_error" not in app._callback_list[-1] + + +def test_clientside_no_output_grouped_inputs(): + app = make_app() + app.clientside_callback( + "function({a}) { dash_clientside.set_props('x', {children: a}); }", + inputs=dict(a=Input("in", "value")), + ) + spec = app._callback_list[-1] + assert spec["no_output"] is True + assert spec["inputs_state_indices"] == dict(a=0) + + +def test_clientside_state_kwarg_grouping(): + app = make_app() + app.clientside_callback( + "function({a, s}) { return a + s; }", + output=Output("out", "children"), + inputs=dict(a=Input("in", "value")), + state=dict(s=State("st", "value")), + ) + spec = app._callback_list[-1] + assert spec["inputs_state_indices"] == dict(a=0, s=1) diff --git a/tests/unit/library/test_grouped_callbacks.py b/tests/unit/library/test_grouped_callbacks.py index d332ea0561..d1090de348 100644 --- a/tests/unit/library/test_grouped_callbacks.py +++ b/tests/unit/library/test_grouped_callbacks.py @@ -155,6 +155,24 @@ def test_callback_input_mixed_grouping(mixed_grouping_size): check_callback_inputs_for_grouping(mixed_grouping_size[0]) +def test_clientside_callback_flat_signature_spec_unchanged(): + """ + Flat clientside signatures must not gain the grouping keys, so existing + apps keep the byte-identical callback spec and renderer code path. + """ + app = dash.Dash() + app.clientside_callback( + ClientsideFunction("foo", "bar"), + Output("output-a", "prop"), + Input("input-a", "prop"), + State("state-a", "prop"), + ) + spec = app._callback_list[-1] + assert "outputs_indices" not in spec + assert "inputs_state_indices" not in spec + assert spec["output"] == "output-a.prop" + + @pytest.mark.parametrize( "grouping", [ @@ -162,43 +180,57 @@ def test_callback_input_mixed_grouping(mixed_grouping_size): dict(a=[0, 1], b=2), ], ) -def test_clientside_callback_grouping_validation(grouping): +def test_clientside_callback_grouping_spec(grouping): """ - Clientside callbacks do not support dependency groupings yet, so we make sure that - these are not allowed through validation. - - This test should be removed when grouping support is added for clientside - callbacks. + Grouped clientside signatures serialize the argument/output groupings + into the callback spec for the renderer to reconstruct. """ app = dash.Dash() - - # Should pass validation with no groupings + outputs = make_dependency_grouping(grouping, [Output]) + inputs = make_dependency_grouping(grouping, [Input]) app.clientside_callback( ClientsideFunction("foo", "bar"), - Output("output-a", "prop"), - Input("input-a", "prop"), + output=outputs, + inputs=inputs, + ) + spec = app._callback_list[-1] + assert spec["outputs_indices"] == make_grouping_by_index( + grouping, list(range(grouping_len(grouping))) ) + assert spec["inputs_state_indices"] == make_grouping_by_index( + grouping, list(range(grouping_len(grouping))) + ) + assert len(spec["inputs"]) == grouping_len(grouping) + assert spec["output"].startswith("..") - # Validation error with output is a grouping - with pytest.raises(dash.exceptions.IncorrectTypeException): - app.clientside_callback( - ClientsideFunction("foo", "bar"), - make_dependency_grouping(grouping, [Output]), - Input("input-a", "prop"), - ) - # Validation error with input is a grouping - with pytest.raises(dash.exceptions.IncorrectTypeException): - app.clientside_callback( - ClientsideFunction("foo", "bar"), - Output("output-a", "prop"), - make_dependency_grouping(grouping, [Input]), - ) +def test_clientside_callback_mixed_input_state_grouping_spec(): + """ + Mixed Input/State dict groupings remap indices into the concatenation of + the flat inputs followed by the flat state. + """ + app = dash.Dash() + app.clientside_callback( + ClientsideFunction("foo", "bar"), + output=Output("output-a", "prop"), + inputs=dict( + a=Input("input-a", "prop"), + s=State("state-a", "prop"), + b=Input("input-b", "prop"), + ), + ) + spec = app._callback_list[-1] + assert spec["inputs_state_indices"] == dict(a=0, s=2, b=1) + assert [i["id"] for i in spec["inputs"]] == ["input-a", "input-b"] + assert [s["id"] for s in spec["state"]] == ["state-a"] + - # Validation error when both are groupings - with pytest.raises(dash.exceptions.IncorrectTypeException): +def test_clientside_callback_malformed_grouping_raises(): + app = dash.Dash() + with pytest.raises(ValueError): app.clientside_callback( ClientsideFunction("foo", "bar"), - make_dependency_grouping(grouping, [Output]), - make_dependency_grouping(grouping, [Input]), + output=Output("output-a", "prop"), + inputs=dict(a=Input("input-a", "prop")), + state=[State("state-a", "prop")], )