From 1b673e34f31761a1552e0053f5a84129d6c433bc Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 24 Jun 2020 19:12:15 -0400 Subject: [PATCH 1/4] fix multiple concurrnt loading states --- dash-renderer/src/observers/loadingMap.ts | 19 +++--- .../integration/renderer/test_multi_output.py | 63 ++++++++++++++++++- 2 files changed, 69 insertions(+), 13 deletions(-) diff --git a/dash-renderer/src/observers/loadingMap.ts b/dash-renderer/src/observers/loadingMap.ts index 8242143083..50b300bfc1 100644 --- a/dash-renderer/src/observers/loadingMap.ts +++ b/dash-renderer/src/observers/loadingMap.ts @@ -1,7 +1,6 @@ import { equals, flatten, - forEach, isEmpty, map, reduce @@ -43,26 +42,22 @@ const observer: IStoreObserverDefinition = { const nextMap: any = isEmpty(loadingPaths) ? null : reduce( - (res, path) => { + (res, {id, property, path}) => { let target = res; - const idprop = { - id: path.id, - property: path.property - }; + const idprop = {id, property}; // Assign all affected props for this path and nested paths target.__dashprivate__idprops__ = target.__dashprivate__idprops__ || []; target.__dashprivate__idprops__.push(idprop); - forEach(p => { - target = (target[p] = - target[p] ?? - p === 'children' ? [] : {} - ) + path.forEach((p, i) => { + target = (target[p] = target[p] ?? + (p === 'children' && typeof path[i + 1] === 'number' ? [] : {}) + ); target.__dashprivate__idprops__ = target.__dashprivate__idprops__ || []; target.__dashprivate__idprops__.push(idprop); - }, path.path); + }); // Assign one affected prop for this path target.__dashprivate__idprop__ = target.__dashprivate__idprop__ || idprop; diff --git a/tests/integration/renderer/test_multi_output.py b/tests/integration/renderer/test_multi_output.py index 4741471420..26a46a397f 100644 --- a/tests/integration/renderer/test_multi_output.py +++ b/tests/integration/renderer/test_multi_output.py @@ -1,4 +1,4 @@ -from multiprocessing import Value +from multiprocessing import Value, Lock import dash from dash.dependencies import Input, Output @@ -164,3 +164,64 @@ def test_rdmo005_set_props_behavior(dash_duo): dash_duo.find_element("#container input").send_keys("hello input w/o ID") dash_duo.wait_for_text_to_equal("#container input", "hello input w/o ID") + + +def test_rdmo006_multi_loading_components(dash_duo): + lock = Lock() + + app = dash.Dash(__name__) + + app.layout = html.Div( + children=[ + html.H3("Edit text input to see loading state"), + dcc.Input(id="input-3", value='Input triggers the loading states'), + dcc.Loading(className="loading-1", children=[ + html.Div(id="loading-output-1") + ], type="default"), + html.Div( + [ + dcc.Loading( + className="loading-2", + children=[html.Div([html.Div(id="loading-output-2")])], + type="circle", + ), + dcc.Loading( + className="loading-3", + children=dcc.Graph(id='graph'), + type="cube", + ) + ] + ), + ], + ) + + @app.callback( + [ + Output("graph", "figure"), + Output("loading-output-1", "children"), + Output("loading-output-2", "children"), + ], + [Input("input-3", "value")]) + def input_triggers_nested(value): + with lock: + return dict(data=[dict(y=[1, 4, 2, 3])]), value, value + + def wait_for_all_spinners(): + dash_duo.find_element('.loading-1 .dash-spinner.dash-default-spinner') + dash_duo.find_element('.loading-2 .dash-spinner.dash-sk-circle') + dash_duo.find_element('.loading-3 .dash-spinner.dash-cube-container') + + def wait_for_no_spinners(): + dash_duo.wait_for_no_elements('.dash-spinner') + + with lock: + dash_duo.start_server(app) + wait_for_all_spinners() + + wait_for_no_spinners() + + with lock: + dash_duo.find_element('#input-3').send_keys('X') + wait_for_all_spinners() + + wait_for_no_spinners() From b012fd65f26cbd20d9ffaea720d6be00d395df44 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 24 Jun 2020 19:23:42 -0400 Subject: [PATCH 2/4] changelog for loading states fix --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e3e398d7..cb788399cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to `dash` will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## UNRELEASED +### Fixed +- [#1310](https://github.com/plotly/dash/pull/1310) Fix a regression since 1.13.0 preventing more than one loading state from being shown at a time. + ## [1.13.3] - 2020-06-19 ## [1.13.2] - 2020-06-18 From 32939efe36ca58cd7fb6c5e937c7077f6d607aab Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 24 Jun 2020 20:52:01 -0400 Subject: [PATCH 3/4] move new loading state test to its own file --- .../renderer/test_loading_states.py | 68 +++++++++++++++++++ .../integration/renderer/test_multi_output.py | 63 +---------------- 2 files changed, 69 insertions(+), 62 deletions(-) create mode 100644 tests/integration/renderer/test_loading_states.py diff --git a/tests/integration/renderer/test_loading_states.py b/tests/integration/renderer/test_loading_states.py new file mode 100644 index 0000000000..4f756aa6c5 --- /dev/null +++ b/tests/integration/renderer/test_loading_states.py @@ -0,0 +1,68 @@ +from multiprocessing import Lock + +import dash +from dash.dependencies import Input, Output + +import dash_core_components as dcc +import dash_html_components as html + + +def test_rdls001_multi_loading_components(dash_duo): + lock = Lock() + + app = dash.Dash(__name__) + + app.layout = html.Div( + children=[ + html.H3("Edit text input to see loading state"), + dcc.Input(id="input-3", value='Input triggers the loading states'), + dcc.Loading(className="loading-1", children=[ + html.Div(id="loading-output-1") + ], type="default"), + html.Div( + [ + dcc.Loading( + className="loading-2", + children=[html.Div([html.Div(id="loading-output-2")])], + type="circle", + ), + dcc.Loading( + className="loading-3", + children=dcc.Graph(id='graph'), + type="cube", + ) + ] + ), + ], + ) + + @app.callback( + [ + Output("graph", "figure"), + Output("loading-output-1", "children"), + Output("loading-output-2", "children"), + ], + [Input("input-3", "value")]) + def input_triggers_nested(value): + with lock: + return dict(data=[dict(y=[1, 4, 2, 3])]), value, value + + def wait_for_all_spinners(): + dash_duo.find_element('.loading-1 .dash-spinner.dash-default-spinner') + dash_duo.find_element('.loading-2 .dash-spinner.dash-sk-circle') + dash_duo.find_element('.loading-3 .dash-spinner.dash-cube-container') + + def wait_for_no_spinners(): + dash_duo.wait_for_no_elements('.dash-spinner') + + with lock: + dash_duo.start_server(app) + wait_for_all_spinners() + + wait_for_no_spinners() + + with lock: + dash_duo.find_element('#input-3').send_keys('X') + wait_for_all_spinners() + + wait_for_no_spinners() diff --git a/tests/integration/renderer/test_multi_output.py b/tests/integration/renderer/test_multi_output.py index 26a46a397f..4741471420 100644 --- a/tests/integration/renderer/test_multi_output.py +++ b/tests/integration/renderer/test_multi_output.py @@ -1,4 +1,4 @@ -from multiprocessing import Value, Lock +from multiprocessing import Value import dash from dash.dependencies import Input, Output @@ -164,64 +164,3 @@ def test_rdmo005_set_props_behavior(dash_duo): dash_duo.find_element("#container input").send_keys("hello input w/o ID") dash_duo.wait_for_text_to_equal("#container input", "hello input w/o ID") - - -def test_rdmo006_multi_loading_components(dash_duo): - lock = Lock() - - app = dash.Dash(__name__) - - app.layout = html.Div( - children=[ - html.H3("Edit text input to see loading state"), - dcc.Input(id="input-3", value='Input triggers the loading states'), - dcc.Loading(className="loading-1", children=[ - html.Div(id="loading-output-1") - ], type="default"), - html.Div( - [ - dcc.Loading( - className="loading-2", - children=[html.Div([html.Div(id="loading-output-2")])], - type="circle", - ), - dcc.Loading( - className="loading-3", - children=dcc.Graph(id='graph'), - type="cube", - ) - ] - ), - ], - ) - - @app.callback( - [ - Output("graph", "figure"), - Output("loading-output-1", "children"), - Output("loading-output-2", "children"), - ], - [Input("input-3", "value")]) - def input_triggers_nested(value): - with lock: - return dict(data=[dict(y=[1, 4, 2, 3])]), value, value - - def wait_for_all_spinners(): - dash_duo.find_element('.loading-1 .dash-spinner.dash-default-spinner') - dash_duo.find_element('.loading-2 .dash-spinner.dash-sk-circle') - dash_duo.find_element('.loading-3 .dash-spinner.dash-cube-container') - - def wait_for_no_spinners(): - dash_duo.wait_for_no_elements('.dash-spinner') - - with lock: - dash_duo.start_server(app) - wait_for_all_spinners() - - wait_for_no_spinners() - - with lock: - dash_duo.find_element('#input-3').send_keys('X') - wait_for_all_spinners() - - wait_for_no_spinners() From a2259221e386bd5c7aee62d8b4c45a4f85196bf5 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Wed, 24 Jun 2020 21:46:50 -0400 Subject: [PATCH 4/4] add chained loading states test and format --- .../renderer/test_loading_states.py | 125 ++++++++++++++++-- 1 file changed, 113 insertions(+), 12 deletions(-) diff --git a/tests/integration/renderer/test_loading_states.py b/tests/integration/renderer/test_loading_states.py index 4f756aa6c5..0e490c32cd 100644 --- a/tests/integration/renderer/test_loading_states.py +++ b/tests/integration/renderer/test_loading_states.py @@ -15,10 +15,12 @@ def test_rdls001_multi_loading_components(dash_duo): app.layout = html.Div( children=[ html.H3("Edit text input to see loading state"), - dcc.Input(id="input-3", value='Input triggers the loading states'), - dcc.Loading(className="loading-1", children=[ - html.Div(id="loading-output-1") - ], type="default"), + dcc.Input(id="input-3", value="Input triggers the loading states"), + dcc.Loading( + className="loading-1", + children=[html.Div(id="loading-output-1")], + type="default", + ), html.Div( [ dcc.Loading( @@ -28,9 +30,9 @@ def test_rdls001_multi_loading_components(dash_duo): ), dcc.Loading( className="loading-3", - children=dcc.Graph(id='graph'), + children=dcc.Graph(id="graph"), type="cube", - ) + ), ] ), ], @@ -42,18 +44,19 @@ def test_rdls001_multi_loading_components(dash_duo): Output("loading-output-1", "children"), Output("loading-output-2", "children"), ], - [Input("input-3", "value")]) + [Input("input-3", "value")], + ) def input_triggers_nested(value): with lock: return dict(data=[dict(y=[1, 4, 2, 3])]), value, value def wait_for_all_spinners(): - dash_duo.find_element('.loading-1 .dash-spinner.dash-default-spinner') - dash_duo.find_element('.loading-2 .dash-spinner.dash-sk-circle') - dash_duo.find_element('.loading-3 .dash-spinner.dash-cube-container') + dash_duo.find_element(".loading-1 .dash-spinner.dash-default-spinner") + dash_duo.find_element(".loading-2 .dash-spinner.dash-sk-circle") + dash_duo.find_element(".loading-3 .dash-spinner.dash-cube-container") def wait_for_no_spinners(): - dash_duo.wait_for_no_elements('.dash-spinner') + dash_duo.wait_for_no_elements(".dash-spinner") with lock: dash_duo.start_server(app) @@ -62,7 +65,105 @@ def wait_for_no_spinners(): wait_for_no_spinners() with lock: - dash_duo.find_element('#input-3').send_keys('X') + dash_duo.find_element("#input-3").send_keys("X") wait_for_all_spinners() wait_for_no_spinners() + + +def test_rdls002_chained_loading_states(dash_duo): + lock1, lock2, lock34 = Lock(), Lock(), Lock() + app = dash.Dash(__name__) + + def loading_wrapped_div(_id, color): + return html.Div( + dcc.Loading( + html.Div( + id=_id, + style={"width": 200, "height": 200, "backgroundColor": color}, + ), + className=_id, + ), + style={"display": "inline-block"}, + ) + + app.layout = html.Div( + [ + html.Button(id="button", children="Start", n_clicks=0), + loading_wrapped_div("output-1", "hotpink"), + loading_wrapped_div("output-2", "rebeccapurple"), + loading_wrapped_div("output-3", "green"), + loading_wrapped_div("output-4", "#FF851B"), + ] + ) + + @app.callback(Output("output-1", "children"), [Input("button", "n_clicks")]) + def update_output_1(n_clicks): + with lock1: + return "Output 1: {}".format(n_clicks) + + @app.callback(Output("output-2", "children"), [Input("output-1", "children")]) + def update_output_2(children): + with lock2: + return "Output 2: {}".format(children) + + @app.callback( + [Output("output-3", "children"), Output("output-4", "children")], + [Input("output-2", "children")], + ) + def update_output_34(children): + with lock34: + return "Output 3: {}".format(children), "Output 4: {}".format(children) + + dash_duo.start_server(app) + + def find_spinners(*nums): + if not nums: + dash_duo.wait_for_no_elements(".dash-spinner") + return + + for n in nums: + dash_duo.find_element(".output-{} .dash-spinner".format(n)) + + assert len(dash_duo.find_elements(".dash-spinner")) == len(nums) + + def find_text(spec): + templates = [ + "Output 1: {}", + "Output 2: Output 1: {}", + "Output 3: Output 2: Output 1: {}", + "Output 4: Output 2: Output 1: {}", + ] + for n, v in spec.items(): + dash_duo.wait_for_text_to_equal( + "#output-{}".format(n), templates[n - 1].format(v) + ) + + find_text({1: 0, 2: 0, 3: 0, 4: 0}) + find_spinners() + + btn = dash_duo.find_element("#button") + # Can't use lock context managers here, because we want to acquire the + # second lock before releasing the first + lock1.acquire() + btn.click() + + find_spinners(1) + find_text({2: 0, 3: 0, 4: 0}) + + lock2.acquire() + lock1.release() + + find_spinners(2) + find_text({1: 1, 3: 0, 4: 0}) + + lock34.acquire() + lock2.release() + + find_spinners(3, 4) + find_text({1: 1, 2: 1}) + + lock34.release() + + find_spinners() + find_text({1: 1, 2: 1, 3: 1, 4: 1})