diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ab0315eb0..38854706a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- [#2009](https://github.com/plotly/dash/pull/2009) Add support for Promises within Client-side callbacks as requested in [#1364](https://github.com/plotly/dash/pull/1364). + ### Fixed - [#2015](https://github.com/plotly/dash/pull/2015) Fix bug [#1854](https://github.com/plotly/dash/issues/1854) in which the combination of row_selectable="single or multi" and filter_action="native" caused the JS error. diff --git a/dash/dash-renderer/src/actions/callbacks.ts b/dash/dash-renderer/src/actions/callbacks.ts index bec4167493..4fafaab16b 100644 --- a/dash/dash-renderer/src/actions/callbacks.ts +++ b/dash/dash-renderer/src/actions/callbacks.ts @@ -200,7 +200,7 @@ const getVals = (input: any) => const zipIfArray = (a: any, b: any) => Array.isArray(a) ? zip(a, b) : [[a, b]]; -function handleClientside( +async function handleClientside( dispatch: any, clientside_function: any, config: any, @@ -246,14 +246,12 @@ function handleClientside( dc.callback_context.states_list = state; dc.callback_context.states = stateDict; - const returnValue = dc[namespace][function_name](...args); + let returnValue = dc[namespace][function_name](...args); + + delete dc.callback_context; if (typeof returnValue?.then === 'function') { - throw new Error( - 'The clientside function returned a Promise. ' + - 'Promises are not supported in Dash clientside ' + - 'right now, but may be in the future.' - ); + returnValue = await returnValue; } zipIfArray(outputs, returnValue).forEach(([outi, reti]) => { @@ -504,15 +502,13 @@ export function executeCallback( if (clientside_function) { try { - return { - data: handleClientside( - dispatch, - clientside_function, - config, - payload - ), + const data = await handleClientside( + dispatch, + clientside_function, + config, payload - }; + ); + return {data, payload}; } catch (error: any) { return {error, payload}; } diff --git a/tests/integration/clientside/assets/clientside.js b/tests/integration/clientside/assets/clientside.js index 71e8dc0abe..6f851d1f6b 100644 --- a/tests/integration/clientside/assets/clientside.js +++ b/tests/integration/clientside/assets/clientside.js @@ -98,5 +98,27 @@ window.dash_clientside.clientside = { } window.callCount += 1; return inputValue.toString(); - } + }, + + chained_promise: function (inputValue) { + return new Promise(function (resolve) { + setTimeout(function () { + resolve(inputValue + "-chained"); + }, 100); + }); + }, + + delayed_promise: function (inputValue) { + return new Promise(function (resolve) { + window.callbackDone = function (deferredValue) { + resolve("clientside-" + inputValue + "-" + deferredValue); + }; + }); + }, + + non_delayed_promise: function (inputValue) { + return new Promise(function (resolve) { + resolve("clientside-" + inputValue); + }); + }, }; diff --git a/tests/integration/clientside/test_clientside.py b/tests/integration/clientside/test_clientside.py index 0160d93d06..150da399e4 100644 --- a/tests/integration/clientside/test_clientside.py +++ b/tests/integration/clientside/test_clientside.py @@ -1,5 +1,5 @@ # -*- coding: UTF-8 -*- -from multiprocessing import Value +from multiprocessing import Value, Lock from dash import Dash, Input, Output, State, ClientsideFunction, ALL, html, dcc from selenium.webdriver.common.keys import Keys @@ -223,30 +223,6 @@ def test_clsd004_clientside_multiple_outputs(dash_duo): dash_duo.wait_for_text_to_equal(selector, expected) -def test_clsd005_clientside_fails_when_returning_a_promise(dash_duo): - app = Dash(__name__, assets_folder="assets") - - app.layout = html.Div( - [ - html.Div(id="input", children="hello"), - html.Div(id="side-effect"), - html.Div(id="output", children="output"), - ] - ) - - app.clientside_callback( - ClientsideFunction("clientside", "side_effect_and_return_a_promise"), - Output("output", "children"), - [Input("input", "children")], - ) - - dash_duo.start_server(app) - - dash_duo.wait_for_text_to_equal("#input", "hello") - dash_duo.wait_for_text_to_equal("#side-effect", "side effect") - dash_duo.wait_for_text_to_equal("#output", "output") - - def test_clsd006_PreventUpdate(dash_duo): app = Dash(__name__, assets_folder="assets") @@ -716,3 +692,140 @@ def test_clsd014_input_output_callback(dash_duo): assert call_count == 2, "initial + changed once" assert dash_duo.get_logs() == [] + + +def test_clsd015_clientside_chained_callbacks_returning_promise(dash_duo): + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + html.Div(id="input", children=["initial"]), + html.Div(id="div-1"), + html.Div(id="div-2"), + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="chained_promise"), + Output("div-1", "children"), + Input("input", "children"), + ) + + @app.callback(Output("div-2", "children"), Input("div-1", "children")) + def callback(value): + return value + "-twice" + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#div-1", "initial-chained") + dash_duo.wait_for_text_to_equal("#div-2", "initial-chained-twice") + + +def test_clsd016_serverside_clientside_shared_input_with_promise(dash_duo): + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + html.Div(id="input", children=["initial"]), + html.Div(id="clientside-div"), + html.Div(id="serverside-div"), + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="delayed_promise"), + Output("clientside-div", "children"), + Input("input", "children"), + ) + + @app.callback(Output("serverside-div", "children"), Input("input", "children")) + def callback(value): + return "serverside-" + value[0] + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#serverside-div", "serverside-initial") + dash_duo.driver.execute_script("window.callbackDone('deferred')") + dash_duo.wait_for_text_to_equal("#clientside-div", "clientside-initial-deferred") + + +def test_clsd017_clientside_serverside_shared_input_with_promise(dash_duo): + lock = Lock() + lock.acquire() + + app = Dash(__name__, assets_folder="assets") + + app.layout = html.Div( + [ + html.Div(id="input", children=["initial"]), + html.Div(id="clientside-div"), + html.Div(id="serverside-div"), + ] + ) + + app.clientside_callback( + ClientsideFunction(namespace="clientside", function_name="non_delayed_promise"), + Output("clientside-div", "children"), + Input("input", "children"), + ) + + @app.callback(Output("serverside-div", "children"), Input("input", "children")) + def callback(value): + with lock: + return "serverside-" + value[0] + "-deferred" + + dash_duo.start_server(app) + + dash_duo.wait_for_text_to_equal("#clientside-div", "clientside-initial") + lock.release() + dash_duo.wait_for_text_to_equal("#serverside-div", "serverside-initial-deferred") + + +def test_clsd018_clientside_inline_async_function(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Div(id="input", children=["initial"]), + html.Div(id="output-div"), + ] + ) + + app.clientside_callback( + """ + async function(input) { + return input + "-inline"; + } + """, + Output("output-div", "children"), + Input("input", "children"), + ) + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output-div", "initial-inline") + + +def test_clsd019_clientside_inline_promise(dash_duo): + app = Dash(__name__) + + app.layout = html.Div( + [ + html.Div(id="input", children=["initial"]), + html.Div(id="output-div"), + ] + ) + + app.clientside_callback( + """ + function(inputValue) { + return new Promise(function (resolve) { + resolve(inputValue + "-inline"); + }); + } + """, + Output("output-div", "children"), + Input("input", "children"), + ) + + dash_duo.start_server(app) + dash_duo.wait_for_text_to_equal("#output-div", "initial-inline")