diff --git a/.circleci/config.yml b/.circleci/config.yml index 8ae9a3857..4d89a1f61 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -81,7 +81,7 @@ jobs: # build main dash git clone --depth 1 https://github.com/plotly/dash.git dash-main cd dash-main && pip install -e .[dev] --progress-bar off && python setup.py sdist && mv dist/* ../packages/ - cd dash-renderer && npm run build + cd dash-renderer && npm ci && npm run build python setup.py sdist && mv dist/* ../../packages/ && cd ../.. # build dcc npm ci && npm run build && python setup.py sdist && mv dist/* ./packages diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfc26492..64c613ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Changed - [#793](https://github.com/plotly/dash-core-components/pull/793) Added title key (i.e. HTML `title` attribute) to option dicts in `dcc.Dropdown` `options[]` list property. ### Fixed +- [#792](https://github.com/plotly/dash-core-components/pull/792) Improved the robustness of `dcc.Store` components, fixing [#456](https://github.com/plotly/dash-core-components/issues/456) whereby persistent storage could become corrupted, and fixing lifecycle issues that prevented adding `Store` components to the page after initial loading. - [#790](https://github.com/plotly/dash-core-components/pull/790) Fixed bug where the dcc.Dropdown dropdown was hidden by the dash_table.DataTable fixed rows and columns. ## [1.9.1] - 2020-04-10 diff --git a/src/components/Store.react.js b/src/components/Store.react.js index 195f20e2e..4268c8ee8 100644 --- a/src/components/Store.react.js +++ b/src/components/Store.react.js @@ -1,46 +1,7 @@ -import {any, includes, isNil, type} from 'ramda'; +import {equals, isNil} from 'ramda'; import React from 'react'; import PropTypes from 'prop-types'; -/** - * Deep equality check to know if the data has changed. - * - * @param {*} newData - New data to compare - * @param {*} oldData - The old data to compare - * @returns {boolean} The data has changed. - */ -function dataChanged(newData, oldData) { - const oldNull = isNil(oldData); - const newNull = isNil(newData); - if (oldNull || newNull) { - return newData !== oldData; - } - const newType = type(newData); - if (newType !== type(oldData)) { - return true; - } - if (newType === 'Array') { - if (newData.length !== oldData.length) { - return true; - } - for (let i = 0; i < newData.length; i++) { - if (dataChanged(newData[i], oldData[i])) { - return true; - } - } - } else if (includes(newType, ['String', 'Number', 'Boolean'])) { - return oldData !== newData; - } else if (newType === 'Object') { - const oldEntries = Object.entries(oldData); - const newEntries = Object.entries(newData); - if (oldEntries.length !== newEntries.length) { - return true; - } - return any(([k, v]) => dataChanged(v, oldData[k]))(newEntries); - } - return false; -} - /** * Abstraction for the memory storage_type to work the same way as local/session * @@ -88,7 +49,13 @@ class WebStore { } getItem(key) { - return JSON.parse(this._storage.getItem(key)); + try { + return JSON.parse(this._storage.getItem(key)); + } catch (e) { + // in case we somehow got a non-JSON value in storage, + // just ignore it. + return null; + } } setItem(key, value) { @@ -150,7 +117,7 @@ export default class Store extends React.Component { } const old = this._backstore.getItem(id); - if (isNil(old) && data) { + if (isNil(old) && !isNil(data)) { // Initial data mount this._backstore.setItem(id, data); setProps({ @@ -159,7 +126,7 @@ export default class Store extends React.Component { return; } - if (dataChanged(old, data)) { + if (!equals(old, data)) { setProps({ data: old, modified_timestamp: this._backstore.getModified(id), @@ -186,11 +153,19 @@ export default class Store extends React.Component { } const old = this._backstore.getItem(id); // Only set the data if it's not the same data. - if (dataChanged(data, old)) { - this._backstore.setItem(id, data); - setProps({ - modified_timestamp: this._backstore.getModified(id), - }); + // If the new data is undefined, we got here by overwriting the entire + // component with a new copy that has no `data` specified - so pull back + // out the old value. + // Note: this still allows you to set data to null + if (!equals(data, old)) { + if (data === undefined) { + setProps({data: old}); + } else { + this._backstore.setItem(id, data); + setProps({ + modified_timestamp: this._backstore.getModified(id), + }); + } } } diff --git a/tests/integration/store/test_component_props.py b/tests/integration/store/test_component_props.py index e0095412b..853baaf2d 100644 --- a/tests/integration/store/test_component_props.py +++ b/tests/integration/store/test_component_props.py @@ -70,3 +70,123 @@ def init_output(ts, data): assert ts == approx( output_data.get("ts"), abs=40 ), "the modified_timestamp should be updated right after the click action" + + +def test_stcp003_initial_falsy(dash_dcc): + app = dash.Dash(__name__) + app.layout = html.Div([html.Div([ + storage_type, + dcc.Store(storage_type=storage_type, id="zero-" + storage_type, data=0), + dcc.Store(storage_type=storage_type, id="false-" + storage_type, data=False), + dcc.Store(storage_type=storage_type, id="null-" + storage_type, data=None), + dcc.Store(storage_type=storage_type, id="empty-" + storage_type, data=""), + ]) for storage_type in ("memory", "local", "session")], id="content") + + dash_dcc.start_server(app) + dash_dcc.wait_for_text_to_equal("#content", "memory\nlocal\nsession") + + for storage_type in ("local", "session"): + getter = getattr(dash_dcc, "get_{}_storage".format(storage_type)) + assert getter("zero-" + storage_type) == 0, storage_type + assert getter("false-" + storage_type) is False, storage_type + assert getter("null-" + storage_type) is None, storage_type + assert getter("empty-" + storage_type) == "", storage_type + + assert not dash_dcc.get_logs() + + +def test_stcp004_remount_store_component(dash_dcc): + app = dash.Dash(__name__, suppress_callback_exceptions=True) + + content = html.Div( + [ + dcc.Store(id="memory", storage_type="memory"), + dcc.Store(id="local", storage_type="local"), + dcc.Store(id="session", storage_type="session"), + html.Button("click me", id="btn"), + html.Button("clear data", id="clear-btn"), + html.Div(id="output"), + ] + ) + + app.layout = html.Div([html.Button("start", id="start"), html.Div(id="content")]) + + @app.callback(Output("content", "children"), [Input("start", "n_clicks")]) + def start(n): + return content if n else "init" + + @app.callback( + Output("output", "children"), + [ + Input("memory", "modified_timestamp"), + Input("local", "modified_timestamp"), + Input("session", "modified_timestamp") + ], + [State("memory", "data"), State("local", "data"), State("session", "data")], + ) + def write_memory(tsm, tsl, tss, datam, datal, datas): + return json.dumps([datam, datal, datas]) + + @app.callback( + [ + Output("local", "clear_data"), + Output("memory", "clear_data"), + Output("session", "clear_data"), + ], + [Input("clear-btn", "n_clicks")], + ) + def on_clear(n_clicks): + if n_clicks is None: + raise PreventUpdate + return True, True, True + + @app.callback( + [ + Output("memory", "data"), + Output("local", "data"), + Output("session", "data"), + ], + [Input("btn", "n_clicks")], + ) + def on_click(n_clicks): + return ({"n_clicks": n_clicks},) * 3 + + dash_dcc.start_server(app) + + dash_dcc.wait_for_text_to_equal("#content", "init") + + dash_dcc.find_element("#start").click() + dash_dcc.wait_for_text_to_equal( + "#output", + '[{"n_clicks": null}, {"n_clicks": null}, {"n_clicks": null}]' + ) + + dash_dcc.find_element("#btn").click() + dash_dcc.wait_for_text_to_equal( + "#output", + '[{"n_clicks": 1}, {"n_clicks": 1}, {"n_clicks": 1}]' + ) + + dash_dcc.find_element("#clear-btn").click() + dash_dcc.wait_for_text_to_equal("#output", "[null, null, null]") + + dash_dcc.find_element("#btn").click() + dash_dcc.wait_for_text_to_equal( + "#output", + '[{"n_clicks": 2}, {"n_clicks": 2}, {"n_clicks": 2}]' + ) + + # now remount content components + dash_dcc.find_element("#start").click() + dash_dcc.wait_for_text_to_equal( + "#output", + '[{"n_clicks": null}, {"n_clicks": null}, {"n_clicks": null}]' + ) + + dash_dcc.find_element("#btn").click() + dash_dcc.wait_for_text_to_equal( + "#output", + '[{"n_clicks": 1}, {"n_clicks": 1}, {"n_clicks": 1}]' + ) + + assert not dash_dcc.get_logs()