diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fbc913ce2..074763624d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). ## [UNRELEASED] +### Changed +- [#1385](https://github.com/plotly/dash/pull/1385) Closes [#1350](https://github.com/plotly/dash/issues/1350) and fixes a previously undefined callback behavior when multiple elements are stacked on top of one another and their `n_clicks` props are used as inputs of the same callback. The callback will now trigger once with all the triggered `n_clicks` props changes. + ### Fixed - [#1384](https://github.com/plotly/dash/pull/1384) Fixed a bug introduced by [#1180](https://github.com/plotly/dash/pull/1180) breaking use of `prevent_initial_call` as a positional arg in callback definitions diff --git a/dash-renderer/src/APIController.react.js b/dash-renderer/src/APIController.react.js index 8479b0fd07..46453046c6 100644 --- a/dash-renderer/src/APIController.react.js +++ b/dash-renderer/src/APIController.react.js @@ -20,6 +20,7 @@ import {applyPersistence} from './persistence'; import {getAppState} from './reducers/constants'; import {STATUS} from './constants/constants'; import {getLoadingState, getLoadingHash} from './utils/TreeContainer'; +import wait from './utils/wait'; export const DashContext = createContext({}); @@ -63,8 +64,11 @@ const UnconnectedContainer = props => { useEffect(() => { if (renderedTree.current) { - renderedTree.current = false; - events.current.emit('rendered'); + (async () => { + renderedTree.current = false; + await wait(0); + events.current.emit('rendered'); + })(); } }); diff --git a/dash-renderer/src/AppContainer.react.js b/dash-renderer/src/AppContainer.react.js index 0dcb5e2a65..d03efb0f16 100644 --- a/dash-renderer/src/AppContainer.react.js +++ b/dash-renderer/src/AppContainer.react.js @@ -2,7 +2,6 @@ import {connect} from 'react-redux'; import React from 'react'; import PropTypes from 'prop-types'; import APIController from './APIController.react'; -import DocumentTitle from './components/core/DocumentTitle.react'; import Loading from './components/core/Loading.react'; import Toolbar from './components/core/Toolbar.react'; import Reloader from './components/core/Reloader.react'; @@ -48,7 +47,6 @@ class UnconnectedAppContainer extends React.Component { {show_undo_redo ? : null} - diff --git a/dash-renderer/src/StoreObserver.ts b/dash-renderer/src/StoreObserver.ts index 4bc82382f0..45b31e8d2c 100644 --- a/dash-renderer/src/StoreObserver.ts +++ b/dash-renderer/src/StoreObserver.ts @@ -21,6 +21,7 @@ interface IStoreObserverState { export interface IStoreObserverDefinition { observer: Observer>; inputs: string[] + [key: string]: any; } export default class StoreObserver { diff --git a/dash-renderer/src/components/core/DocumentTitle.react.js b/dash-renderer/src/components/core/DocumentTitle.react.js deleted file mode 100644 index bfea61831e..0000000000 --- a/dash-renderer/src/components/core/DocumentTitle.react.js +++ /dev/null @@ -1,51 +0,0 @@ -import {connect} from 'react-redux'; -import {Component} from 'react'; -import PropTypes from 'prop-types'; - -class DocumentTitle extends Component { - constructor(props) { - super(props); - const {update_title} = props.config; - this.state = { - title: document.title, - update_title, - }; - } - - UNSAFE_componentWillReceiveProps(props) { - if (!this.state.update_title) { - // Let callbacks or other components have full control over title - return; - } - if (props.isLoading) { - this.setState({title: document.title}); - if (this.state.update_title) { - document.title = this.state.update_title; - } - } else { - if (document.title === this.state.update_title) { - document.title = this.state.title; - } else { - this.setState({title: document.title}); - } - } - } - - shouldComponentUpdate() { - return false; - } - - render() { - return null; - } -} - -DocumentTitle.propTypes = { - isLoading: PropTypes.bool.isRequired, - config: PropTypes.shape({update_title: PropTypes.string}), -}; - -export default connect(state => ({ - isLoading: state.isLoading, - config: state.config, -}))(DocumentTitle); diff --git a/dash-renderer/src/observers/documentTitle.ts b/dash-renderer/src/observers/documentTitle.ts new file mode 100644 index 0000000000..3cbd893ab2 --- /dev/null +++ b/dash-renderer/src/observers/documentTitle.ts @@ -0,0 +1,58 @@ +import { IStoreObserverDefinition } from '../StoreObserver'; +import { IStoreState } from '../store'; + +const updateTitle = (getState: () => IStoreState) => { + const { + config, + isLoading + } = getState(); + + const update_title = config?.update_title; + + if (!update_title) { + return; + } + + if (isLoading) { + if (document.title !== update_title) { + observer.title = document.title; + document.title = update_title; + } + } else { + if (document.title === update_title) { + document.title = observer.title; + } else { + observer.title = document.title; + } + } +}; + +const observer: IStoreObserverDefinition = { + inputs: ['isLoading'], + mutationObserver: undefined, + observer: ({ + getState + }) => { + const { + config + } = getState(); + + if (observer.config !== config) { + observer.config = config; + observer.mutationObserver?.disconnect(); + observer.mutationObserver = new MutationObserver(() => updateTitle(getState)); + + const title = document.querySelector('title'); + if (title) { + observer.mutationObserver.observe( + title, + { subtree: true, childList: true, attributes: true, characterData: true } + ); + } + } + + updateTitle(getState); + } +}; + +export default observer; diff --git a/dash-renderer/src/observers/requestedCallbacks.ts b/dash-renderer/src/observers/requestedCallbacks.ts index 2d787a43a1..bae79e4d5a 100644 --- a/dash-renderer/src/observers/requestedCallbacks.ts +++ b/dash-renderer/src/observers/requestedCallbacks.ts @@ -4,12 +4,17 @@ import { difference, filter, flatten, + forEach, groupBy, includes, intersection, isEmpty, isNil, map, + mergeLeft, + mergeWith, + pluck, + reduce, values } from 'ramda'; @@ -17,16 +22,16 @@ import { IStoreState } from '../store'; import { aggregateCallbacks, - removeRequestedCallbacks, removePrioritizedCallbacks, removeExecutingCallbacks, removeWatchedCallbacks, - addRequestedCallbacks, addPrioritizedCallbacks, addExecutingCallbacks, addWatchedCallbacks, removeBlockedCallbacks, - addBlockedCallbacks + addBlockedCallbacks, + addRequestedCallbacks, + removeRequestedCallbacks } from '../actions/callbacks'; import { isMultiValued } from '../actions/dependencies'; @@ -45,17 +50,23 @@ import { IBlockedCallback } from '../types/callbacks'; +import wait from './../utils/wait'; + import { getPendingCallbacks } from '../utils/callbacks'; import { IStoreObserverDefinition } from '../StoreObserver'; const observer: IStoreObserverDefinition = { - observer: ({ + observer: async ({ dispatch, getState }) => { + await wait(0); + const { callbacks, callbacks: { prioritized, blocked, executing, watched, stored }, paths } = getState(); let { callbacks: { requested } } = getState(); + const initialRequested = requested.slice(0); + const pendingCallbacks = getPendingCallbacks(callbacks); /* @@ -78,17 +89,37 @@ const observer: IStoreObserverDefinition = { 1. Remove duplicated `requested` callbacks - give precedence to newer callbacks over older ones */ - /* - Extract all but the first callback from each IOS-key group - these callbacks are duplicates. - */ - const rDuplicates = flatten(map( - group => group.slice(0, -1), - values( - groupBy( - getUniqueIdentifier, - requested - ) + let rDuplicates: ICallback[] = []; + let rMergedDuplicates: ICallback[] = []; + + forEach(group => { + if (group.length === 1) { + // keep callback if its the only one of its kind + rMergedDuplicates.push(group[0]); + } else { + const initial = group.find(cb => cb.initialCall); + if (initial) { + // drop the initial callback if it's not alone + rDuplicates.push(initial); + } + + const groupWithoutInitial = group.filter(cb => cb !== initial); + if (groupWithoutInitial.length === 1) { + // if there's only one callback beside the initial one, keep that callback + rMergedDuplicates.push(groupWithoutInitial[0]); + } else { + // otherwise merge all remaining callbacks together + rDuplicates = concat(rDuplicates, groupWithoutInitial); + rMergedDuplicates.push(mergeLeft({ + changedPropIds: reduce(mergeWith(Math.max), {}, pluck('changedPropIds', groupWithoutInitial)), + executionGroup: filter(exg => !!exg, pluck('executionGroup', groupWithoutInitial)).slice(-1)[0] + }, groupWithoutInitial.slice(-1)[0]) as ICallback); + } + } + }, values( + groupBy( + getUniqueIdentifier, + requested ) )); @@ -97,7 +128,7 @@ const observer: IStoreObserverDefinition = { Clean up the `requested` list - during the dispatch phase, duplicates will be removed for real */ - requested = difference(requested, rDuplicates); + requested = rMergedDuplicates; /* 2. Remove duplicated `prioritized`, `executing` and `watching` callbacks @@ -312,16 +343,24 @@ const observer: IStoreObserverDefinition = { dropped ); + requested = difference( + requested, + readyCallbacks + ); + + const added = difference(requested, initialRequested); + const removed = difference(initialRequested, requested); + dispatch(aggregateCallbacks([ + // Clean up requested callbacks + added.length ? addRequestedCallbacks(added) : null, + removed.length ? removeRequestedCallbacks(removed) : null, // Clean up duplicated callbacks - rDuplicates.length ? removeRequestedCallbacks(rDuplicates) : null, pDuplicates.length ? removePrioritizedCallbacks(pDuplicates) : null, bDuplicates.length ? removeBlockedCallbacks(bDuplicates) : null, eDuplicates.length ? removeExecutingCallbacks(eDuplicates) : null, wDuplicates.length ? removeWatchedCallbacks(wDuplicates) : null, // Prune callbacks - rRemoved.length ? removeRequestedCallbacks(rRemoved) : null, - rAdded.length ? addRequestedCallbacks(rAdded) : null, pRemoved.length ? removePrioritizedCallbacks(pRemoved) : null, pAdded.length ? addPrioritizedCallbacks(pAdded) : null, bRemoved.length ? removeBlockedCallbacks(bRemoved) : null, @@ -330,15 +369,7 @@ const observer: IStoreObserverDefinition = { eAdded.length ? addExecutingCallbacks(eAdded) : null, wRemoved.length ? removeWatchedCallbacks(wRemoved) : null, wAdded.length ? addWatchedCallbacks(wAdded) : null, - // Prune circular callbacks - rCirculars.length ? removeRequestedCallbacks(rCirculars) : null, - // Prune circular assumptions - oldBlocked.length ? removeRequestedCallbacks(oldBlocked) : null, - newBlocked.length ? addRequestedCallbacks(newBlocked) : null, - // Drop non-triggered initial callbacks - dropped.length ? removeRequestedCallbacks(dropped) : null, // Promote callbacks - readyCallbacks.length ? removeRequestedCallbacks(readyCallbacks) : null, readyCallbacks.length ? addPrioritizedCallbacks(readyCallbacks) : null ])); }, diff --git a/dash-renderer/src/store.ts b/dash-renderer/src/store.ts index 3b26f75a92..1989941835 100644 --- a/dash-renderer/src/store.ts +++ b/dash-renderer/src/store.ts @@ -7,6 +7,7 @@ import { ICallbacksState } from './reducers/callbacks'; import { LoadingMapState } from './reducers/loadingMap'; import { IsLoadingState } from './reducers/isLoading'; +import documentTitle from './observers/documentTitle'; import executedCallbacks from './observers/executedCallbacks'; import executingCallbacks from './observers/executingCallbacks'; import isLoading from './observers/isLoading' @@ -33,6 +34,7 @@ const storeObserver = new StoreObserver(); const setObservers = once(() => { const observe = storeObserver.observe; + observe(documentTitle); observe(isLoading); observe(loadingMap); observe(requestedCallbacks); diff --git a/dash-renderer/src/utils/wait.ts b/dash-renderer/src/utils/wait.ts new file mode 100644 index 0000000000..10a2a9ed79 --- /dev/null +++ b/dash-renderer/src/utils/wait.ts @@ -0,0 +1,8 @@ +export default async (duration: number) => { + let _resolve: any; + const p = new Promise(resolve => _resolve = resolve); + + setTimeout(_resolve, duration); + + return p; +} diff --git a/tests/integration/callbacks/test_basic_callback.py b/tests/integration/callbacks/test_basic_callback.py index 13276b1899..afbd81cd97 100644 --- a/tests/integration/callbacks/test_basic_callback.py +++ b/tests/integration/callbacks/test_basic_callback.py @@ -1,5 +1,5 @@ import json -from multiprocessing import Value +from multiprocessing import Lock, Value import pytest @@ -9,9 +9,12 @@ import dash from dash.dependencies import Input, Output, State from dash.exceptions import PreventUpdate +from dash.testing import wait def test_cbsc001_simple_callback(dash_duo): + lock = Lock() + app = dash.Dash(__name__) app.layout = html.Div( [ @@ -23,8 +26,9 @@ def test_cbsc001_simple_callback(dash_duo): @app.callback(Output("output-1", "children"), [Input("input", "value")]) def update_output(value): - call_count.value = call_count.value + 1 - return value + with lock: + call_count.value = call_count.value + 1 + return value dash_duo.start_server(app) @@ -34,9 +38,11 @@ def update_output(value): input_ = dash_duo.find_element("#input") dash_duo.clear_input(input_) - input_.send_keys("hello world") + for key in "hello world": + with lock: + input_.send_keys(key) - assert dash_duo.find_element("#output-1").text == "hello world" + wait.until(lambda: dash_duo.find_element("#output-1").text == "hello world", 2) dash_duo.percy_snapshot(name="simple-callback-hello-world") assert call_count.value == 2 + len("hello world"), "initial count + each key stroke" @@ -345,6 +351,8 @@ def set_path(n): def test_cbsc008_wildcard_prop_callbacks(dash_duo): + lock = Lock() + app = dash.Dash(__name__) app.layout = html.Div( [ @@ -369,8 +377,9 @@ def test_cbsc008_wildcard_prop_callbacks(dash_duo): @app.callback(Output("output-1", "data-cb"), [Input("input", "value")]) def update_data(value): - input_call_count.value += 1 - return value + with lock: + input_call_count.value += 1 + return value @app.callback(Output("output-1", "children"), [Input("output-1", "data-cb")]) def update_text(data): @@ -382,7 +391,10 @@ def update_text(data): input1 = dash_duo.find_element("#input") dash_duo.clear_input(input1) - input1.send_keys("hello world") + + for key in "hello world": + with lock: + input1.send_keys(key) dash_duo.wait_for_text_to_equal("#output-1", "hello world") dash_duo.percy_snapshot(name="wildcard-callback-2") diff --git a/tests/integration/callbacks/test_callback_context.py b/tests/integration/callbacks/test_callback_context.py index f4f4552c4c..3eabe92e64 100644 --- a/tests/integration/callbacks/test_callback_context.py +++ b/tests/integration/callbacks/test_callback_context.py @@ -1,4 +1,5 @@ import json +import operator import pytest import dash_html_components as html @@ -9,6 +10,9 @@ from dash.dependencies import Input, Output from dash.exceptions import PreventUpdate, MissingCallbackContextException +import dash.testing.wait as wait + +from selenium.webdriver.common.action_chains import ActionChains def test_cbcx001_modified_response(dash_duo): @@ -96,3 +100,237 @@ def report_triggered(n): 'triggered is truthy, has prop/id ["btn", "n_clicks"], and full value ' '[{"prop_id": "btn.n_clicks", "value": 1}]', ) + + +@pytest.mark.DASH1350 +def test_cbcx005_grouped_clicks(dash_duo): + class context: + calls = 0 + callback_contexts = [] + clicks = dict() + + app = Dash(__name__) + app.layout = html.Div( + [ + html.Button("Button 0", id="btn0"), + html.Div( + [ + html.Button("Button 1", id="btn1"), + html.Div( + [html.Div(id="div3"), html.Button("Button 2", id="btn2")], + id="div2", + style=dict(backgroundColor="yellow", padding="50px"), + ), + ], + id="div1", + style=dict(backgroundColor="blue", padding="50px"), + ), + ], + id="div0", + style=dict(backgroundColor="red", padding="50px"), + ) + + @app.callback( + Output("div3", "children"), + [ + Input("div1", "n_clicks"), + Input("div2", "n_clicks"), + Input("btn0", "n_clicks"), + Input("btn1", "n_clicks"), + Input("btn2", "n_clicks"), + ], + prevent_initial_call=True, + ) + def update(div1, div2, btn0, btn1, btn2): + context.calls = context.calls + 1 + context.callback_contexts.append(callback_context.triggered) + context.clicks["div1"] = div1 + context.clicks["div2"] = div2 + context.clicks["btn0"] = btn0 + context.clicks["btn1"] = btn1 + context.clicks["btn2"] = btn2 + + def click(target): + ActionChains(dash_duo.driver).move_to_element_with_offset( + target, 5, 5 + ).click().perform() + + dash_duo.start_server(app) + click(dash_duo.find_element("#btn0")) + assert context.calls == 1 + keys = list(map(operator.itemgetter("prop_id"), context.callback_contexts[-1:][0])) + assert len(keys) == 1 + assert "btn0.n_clicks" in keys + + assert context.clicks.get("btn0") == 1 + assert context.clicks.get("btn1") is None + assert context.clicks.get("btn2") is None + assert context.clicks.get("div1") is None + assert context.clicks.get("div2") is None + + click(dash_duo.find_element("#div1")) + assert context.calls == 2 + keys = list(map(operator.itemgetter("prop_id"), context.callback_contexts[-1:][0])) + assert len(keys) == 1 + assert "div1.n_clicks" in keys + + assert context.clicks.get("btn0") == 1 + assert context.clicks.get("btn1") is None + assert context.clicks.get("btn2") is None + assert context.clicks.get("div1") == 1 + assert context.clicks.get("div2") is None + + click(dash_duo.find_element("#btn1")) + assert context.calls == 3 + keys = list(map(operator.itemgetter("prop_id"), context.callback_contexts[-1:][0])) + assert len(keys) == 2 + assert "btn1.n_clicks" in keys + assert "div1.n_clicks" in keys + + assert context.clicks.get("btn0") == 1 + assert context.clicks.get("btn1") == 1 + assert context.clicks.get("btn2") is None + assert context.clicks.get("div1") == 2 + assert context.clicks.get("div2") is None + + click(dash_duo.find_element("#div2")) + assert context.calls == 4 + keys = list(map(operator.itemgetter("prop_id"), context.callback_contexts[-1:][0])) + assert len(keys) == 2 + assert "div1.n_clicks" in keys + assert "div2.n_clicks" in keys + + assert context.clicks.get("btn0") == 1 + assert context.clicks.get("btn1") == 1 + assert context.clicks.get("btn2") is None + assert context.clicks.get("div1") == 3 + assert context.clicks.get("div2") == 1 + + click(dash_duo.find_element("#btn2")) + assert context.calls == 5 + keys = list(map(operator.itemgetter("prop_id"), context.callback_contexts[-1:][0])) + assert len(keys) == 3 + assert "btn2.n_clicks" in keys + assert "div1.n_clicks" in keys + assert "div2.n_clicks" in keys + + assert context.clicks.get("btn0") == 1 + assert context.clicks.get("btn1") == 1 + assert context.clicks.get("btn2") == 1 + assert context.clicks.get("div1") == 4 + assert context.clicks.get("div2") == 2 + + +@pytest.mark.DASH1350 +def test_cbcx006_initial_callback_predecessor(dash_duo): + class context: + calls = 0 + callback_contexts = [] + + app = Dash(__name__) + app.layout = html.Div( + [ + html.Div( + style={"display": "block"}, + children=[ + html.Div( + [ + html.Label("ID: input-number-1"), + dcc.Input(id="input-number-1", type="number", value=0), + ] + ), + html.Div( + [ + html.Label("ID: input-number-2"), + dcc.Input(id="input-number-2", type="number", value=0), + ] + ), + html.Div( + [ + html.Label("ID: sum-number"), + dcc.Input( + id="sum-number", type="number", value=0, disabled=True + ), + ] + ), + ], + ), + html.Div(id="results"), + ] + ) + + @app.callback( + Output("sum-number", "value"), + [Input("input-number-1", "value"), Input("input-number-2", "value")], + ) + def update_sum_number(n1, n2): + context.calls = context.calls + 1 + context.callback_contexts.append(callback_context.triggered) + + return n1 + n2 + + @app.callback( + Output("results", "children"), + [ + Input("input-number-1", "value"), + Input("input-number-2", "value"), + Input("sum-number", "value"), + ], + ) + def update_results(n1, n2, nsum): + context.calls = context.calls + 1 + context.callback_contexts.append(callback_context.triggered) + + return [ + "{} + {} = {}".format(n1, n2, nsum), + html.Br(), + "ctx.triggered={}".format(callback_context.triggered), + ] + + dash_duo.start_server(app) + + # Initial Callbacks + wait.until(lambda: context.calls == 2, 2) + wait.until(lambda: len(context.callback_contexts) == 2, 2) + + keys0 = list(map(operator.itemgetter("prop_id"), context.callback_contexts[0])) + # Special case present for backward compatibility + assert len(keys0) == 1 + assert "." in keys0 + + keys1 = list(map(operator.itemgetter("prop_id"), context.callback_contexts[1])) + assert len(keys1) == 1 + assert "sum-number.value" in keys1 + + # User action & followup callbacks + dash_duo.find_element("#input-number-1").click() + dash_duo.find_element("#input-number-1").send_keys("1") + + wait.until(lambda: context.calls == 4, 2) + wait.until(lambda: len(context.callback_contexts) == 4, 2) + + keys0 = list(map(operator.itemgetter("prop_id"), context.callback_contexts[2])) + # Special case present for backward compatibility + assert len(keys0) == 1 + assert "input-number-1.value" in keys0 + + keys1 = list(map(operator.itemgetter("prop_id"), context.callback_contexts[3])) + assert len(keys1) == 2 + assert "sum-number.value" in keys1 + assert "input-number-1.value" in keys1 + + dash_duo.find_element("#input-number-2").click() + dash_duo.find_element("#input-number-2").send_keys("1") + + wait.until(lambda: context.calls == 6, 2) + wait.until(lambda: len(context.callback_contexts) == 6, 2) + + keys0 = list(map(operator.itemgetter("prop_id"), context.callback_contexts[4])) + # Special case present for backward compatibility + assert len(keys0) == 1 + assert "input-number-2.value" in keys0 + + keys1 = list(map(operator.itemgetter("prop_id"), context.callback_contexts[5])) + assert len(keys1) == 2 + assert "sum-number.value" in keys1 + assert "input-number-2.value" in keys1 diff --git a/tests/integration/devtools/test_callback_validation.py b/tests/integration/devtools/test_callback_validation.py index a006e23eec..4f57e01254 100644 --- a/tests/integration/devtools/test_callback_validation.py +++ b/tests/integration/devtools/test_callback_validation.py @@ -677,7 +677,7 @@ def c2(children): @app.callback([Output("a", "children")], [Input("c", "children")]) def c3(children): - return children + return (children,) dash_duo.start_server(app, **debugging) diff --git a/tests/integration/renderer/test_multi_output.py b/tests/integration/renderer/test_multi_output.py index 4741471420..433301ea57 100644 --- a/tests/integration/renderer/test_multi_output.py +++ b/tests/integration/renderer/test_multi_output.py @@ -1,8 +1,9 @@ -from multiprocessing import Value +from multiprocessing import Lock, Value import dash from dash.dependencies import Input, Output from dash.exceptions import PreventUpdate +from dash.testing import wait import dash_core_components as dcc import dash_html_components as html @@ -47,6 +48,8 @@ def update_output(n_clicks): def test_rdmo002_multi_outputs_on_single_component(dash_duo): + lock = Lock() + call_count = Value("i") app = dash.Dash(__name__) @@ -66,8 +69,9 @@ def test_rdmo002_multi_outputs_on_single_component(dash_duo): [Input("input", "value")], ) def update_output(value): - call_count.value += 1 - return [value, {"fontFamily": value}, value] + with lock: + call_count.value += 1 + return [value, {"fontFamily": value}, value] dash_duo.start_server(app) @@ -79,7 +83,9 @@ def update_output(value): assert call_count.value == 1 - dash_duo.find_element("#input").send_keys(" hello") + for key in " hello": + with lock: + dash_duo.find_element("#input").send_keys(key) dash_duo.wait_for_text_to_equal("#output-container", "dash hello") _html = dash_duo.find_element("#output-container").get_property("innerHTML") @@ -88,7 +94,7 @@ def update_output(value): 'style="font-family: "dash hello";">dash hello' ) - assert call_count.value == 7 + wait.until(lambda: call_count.value == 7, 3) def test_rdmo003_single_output_as_multi(dash_duo):