Skip to content
This repository has been archived by the owner on Jun 3, 2024. It is now read-only.

Ensure that components are still in the DOM when they are loading. #740

Merged
merged 28 commits into from
Apr 9, 2020

Conversation

shammamah-zz
Copy link
Contributor

@shammamah-zz shammamah-zz commented Jan 22, 2020

Closes #674.

Also these are all the same thing:
Closes #780
Closes #756
Closes #684

About

Since the children property of the dcc.Loading component is not rendered when loading_state.is_loading is true, this leads to some information about user interactions being lost when a graph (specifically, a map) is wrapped in the loading component. In this PR, we still render the children but with visibility: hidden, and display the spinner over top of it. The component is not visible, but it still takes up the same amount of space when it is loading, which avoids issues that have to do with divs shifting up or down during loading if the spinner is smaller or larger than the children.

We might want to instead always return this "wrapper" div that contains both the spinner and the component, and only change the visibility when loading_state.is_loading changes. One issue that stems from this is that the component itself would be moved "down" a level in the DOM, which might affect the CSS/layout of different apps.

Before

Jan-23-2020 15-51-41

After

Jan-23-2020 15-56-54

Sample app code

from urllib.request import urlopen
import json
import pandas as pd
import plotly.graph_objects as go
import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output, State
from dash.exceptions import PreventUpdate
from time import sleep


with urlopen('https://raw.githubusercontent.com/plotly/datasets/master/geojson-counties-fips.json') as response:
    counties = json.load(response)


df = pd.read_csv("https://raw.githubusercontent.com/plotly/datasets/master/fips-unemp-16.csv",
                 dtype={"fips": str})

app = dash.Dash(__name__)
server = app.server

mapbox_token = "pk.eyJ1IjoicGxvdGx5bWFwYm94IiwiYSI6ImNqdnBvNDMyaTAxYzkzeW5ubWdpZ2VjbmMifQ.TXcBE-xg9BFdV2ocecc_7g"

fig = go.Figure(go.Choroplethmapbox(geojson=counties, locations=df.fips, z=df.unemp,
                                    colorscale="Viridis", zmin=0, zmax=12,
                                    marker_opacity=0.5, marker_line_width=0))
fig.update_layout(mapbox_style="carto-positron",
                  mapbox_accesstoken=mapbox_token,
                  mapbox_zoom=3, mapbox_center={"lat": 37.0902, "lon": -95.7129})
fig.update_layout(margin={"r": 0, "t": 0, "l": 0, "b": 0}, uirevision=True)

app.layout = html.Div(
    children=[
        html.Button(id='btn', children='update', n_clicks=0),
        dcc.Loading(dcc.Graph(id='mapbox-loading', figure=fig), type='default'),
        html.Div(style={'backgroundColor': 'pink'}, children='hello')]
)


@app.callback(
    Output('mapbox-loading', 'figure'),
    [Input('btn', 'n_clicks')]
)
def update(click):
    if click is None or click == 0:
        raise PreventUpdate
    sleep(2)
    fig.update_layout(mapbox_style="light")
    return fig


if __name__ == '__main__':
    app.run_server(debug=True, port=8053)

Copy link
Contributor

@Marc-Andre-Rivet Marc-Andre-Rivet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing snapshot baseline and changelog.

I expect a new test that ensures the state wasn't lost before / after the component was in loading state.

.find('.dash-spinner')
.parent()
.html()
).toMatchSnapshot('Loading with is_loading=true');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the corresponding snapshot baseline in this PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This snapshot is already here:

exports[`Loading renders: Loading with is_loading=true 1`] = `

The baseline is the same, but the test itself had to change because I moved things "down" in the DOM.

@shammamah-zz
Copy link
Contributor Author

Running into some strange test failures.

This test

def test_grbs002_wrapped_graph_has_no_infinite_loop(dash_dcc, is_eager):
fails for both values of is_eager on the CI machine. Upon running the tests locally, they both pass.

The versions of Python, all of the Python packages, and chromedriver are the same on my computer and the CI machine.

Removing this line

allows for is_eager=True to pass on the CI machine.

Relevant CI run: https://circleci.com/gh/plotly/dash-core-components/17161#tests/containers/1

@alexcjohnson
Copy link
Collaborator

Some tests are currently failing because I didn't put className onto the wrapper div when loading is done, I only put it on the spinner itself, when the spinner is present. Is it useful for this class to be applied to the wrapper? At first glance I'd think this is actually unwanted, because then you can't use this class to target the spinner.

@@ -0,0 +1,5 @@
[pytest]
testpaths = tests/
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having this line cuts test discovery time locally from ~10 seconds to <1sec - particularly nice when calling pytest -k ...

I didn't pay attention to the other lines, just copied from dash

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

@@ -23,7 +28,7 @@ test('Loading renders without loading_state', () => {
</Loading>
);

expect(loading.html()).toEqual('<div>Loading is done!</div>');
expect(loading.html()).toEqual('<div>Loading is done!</div><div></div>');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spinner now leaves its wrapper div around even after it disappears. I did it this way to ensure React had no chance of being confused about the identity of the real children components - loading or not doesn't change the immediate children of the outer containing div at all. Maybe overly paranoid, but didn't seem like it would hurt, right?

dash_dcc.start_server(store_app)

assert dash_dcc.wait_for_contains_text("#output", store_app.uuid)

dash_dcc.multiple_click("#btn", 3)
assert dash_dcc.get_local_storage() == {"n_clicks": 3}
wait.until(lambda: dash_dcc.get_local_storage() == {"n_clicks": 3}, timeout=1)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a bare assert this gave spurious errors.


test_identity = (
"var gd_ = document.querySelector('.js-plotly-plot');"
"return gd_ === window.gd && gd_.__test__ === 'boo';"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gd_.__test__ === 'boo' should be redundant after we already have gd_ === window.gd to show the DOM element is unchanged, just being paranoid again...

type(this.props.children) !== 'Object' ||
type(this.props.children) !== 'Function'
) {
return <div className={className}>{this.props.children}</div>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this part, right? Agreed, it isn't clear to me why this was there.


@app.callback(Output("div-1", "children"), [Input("root", "n_clicks")])
def updateDiv(children):
with lock:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow very nice 💯

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the last test here (006) is new, the others I just pulled out of test_integration_1.py into the dash.testing format before fixing them for the className update

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@HammadTheOne, @harryturr When writing tests involving callbacks, using a Lock like ☝️ is not always necessary but is the best practice. Gives you full control of when the callback and your test conditions will be run in respect to one another.

with lock:
dash_dcc.start_server(app)
dash_dcc.find_element(".loading .dash-spinner")
dash_dcc.find_element("#graph .js-plotly-plot")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we also validate that the graph isn't visible while loading?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did that with some of the simpler items eg:

dash_dcc.wait_for_text_to_equal("#btn-3", "")

it looks like it's empty even though it actually still has text in it. Not sure exactly how to do that for a graph though - I suppose I could look directly at the visibility style on the element that sets it to hidden.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If equivalent, all good :D

CHANGELOG.md Outdated Show resolved Hide resolved
Copy link
Member

@chriddyp chriddyp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💃

Co-Authored-By: Chris Parmer <chris@plot.ly>
@Marc-Andre-Rivet
Copy link
Contributor

Marc-Andre-Rivet commented Apr 9, 2020

Seems fine in IE11, we are apparently using the part of flex that actually works
image

assert dash_dcc.driver.execute_script(test_identity)
assert get_graph_visibility() == "hidden"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙇

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
4 participants