From 6a3ef759bb2f31fb36294263779e7dbb7369f13d Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Sun, 26 Apr 2020 02:01:57 -0400 Subject: [PATCH 1/5] local setup for test components --- CONTRIBUTING.md | 2 ++ package.json | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a44795a91..23a05fc9b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,6 +19,8 @@ $ cd dash-renderer $ npm run build # or `renderer build` # install dash-renderer for development $ pip install -e . +# build and install components used in tests +$ npm run setup-tests # you should see both dash and dash-renderer are pointed to local source repos $ pip list | grep dash ``` diff --git a/package.json b/package.json index 063cf748d2..b3a650973f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "format": "run-s private::format.*", "initialize": "run-s private::initialize.*", "lint": "run-s private::lint.*", - "test.integration": "run-s private::test.setup-* private::test.integration-*", + "setup-tests": "run-s private::test.setup-*", + "test.integration": "run-s setup-tests private::test.integration-*", "test.unit": "run-s private::test.unit-**" }, "devDependencies": { From 9c7a2412d239ebcd395ff9ed3136eae967267e8c Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Sun, 26 Apr 2020 03:05:07 -0400 Subject: [PATCH 2/5] fix #1200 - all inputs missing acts like PreventUpdate except when all inputs have multivalued wildcards and all outputs exist --- dash-renderer/src/actions/index.js | 104 +++- .../callbacks/test_missing_inputs.py | 484 ++++++++++++++++++ .../devtools/test_callback_validation.py | 11 +- 3 files changed, 573 insertions(+), 26 deletions(-) create mode 100644 tests/integration/callbacks/test_missing_inputs.py diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 999ade8569..239ba5cb77 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -154,9 +154,6 @@ function unwrapIfNotMulti(paths, idProps, spec, anyVals, depType) { ']' ); } - // TODO: unwrapped list of wildcard ids? - // eslint-disable-next-line no-console - console.log(paths.objs); throw new ReferenceError( 'A nonexistent object was used in an `' + depType + @@ -247,20 +244,47 @@ async function fireReadyCallbacks(dispatch, getState, callbacks) { let payload; try { - const outputs = allOutputs.map((out, i) => - unwrapIfNotMulti( - paths, - map(pick(['id', 'property']), out), - cb.callback.outputs[i], - cb.anyVals, - 'Output' - ) - ); + const inVals = fillVals(paths, layout, cb, inputs, 'Input', true); + + const preventCallback = () => { + removeCallbackFromPending(); + // no server call here; for performance purposes pretend this is + // a clientside callback and defer fireNext for the end + // of the currently-ready callbacks. + hasClientSide = true; + return null; + }; + + if (inVals === null) { + return preventCallback(); + } + + let outputs; + try { + outputs = allOutputs.map((out, i) => + unwrapIfNotMulti( + paths, + map(pick(['id', 'property']), out), + cb.callback.outputs[i], + cb.anyVals, + 'Output' + ) + ); + } catch (e) { + if (e instanceof ReferenceError && !flatten(inVals).length) { + // This case is all-empty multivalued wildcard inputs, + // which we would normally fire the callback for, except + // some outputs are missing. So instead we treat it like + // regular missing inputs and just silently prevent it. + return preventCallback(); + } + throw e; + } payload = { output, outputs: isMultiOutputProp(output) ? outputs : outputs[0], - inputs: fillVals(paths, layout, cb, inputs, 'Input'), + inputs: inVals, changedPropIds: keys(cb.changedPropIds), }; if (cb.callback.state.length) { @@ -360,7 +384,7 @@ async function fireReadyCallbacks(dispatch, getState, callbacks) { updatePending(pendingCallbacks, without(updated, allPropIds)); } - function handleError(err) { + function removeCallbackFromPending() { const {pendingCallbacks} = getState(); if (requestIsActive(pendingCallbacks, resolvedId, requestId)) { // Skip all prop updates from this callback, and remove @@ -368,6 +392,10 @@ async function fireReadyCallbacks(dispatch, getState, callbacks) { // that have other changed inputs will still fire. updatePending(pendingCallbacks, allPropIds); } + } + + function handleError(err) { + removeCallbackFromPending(); const outputs = payload ? map(combineIdAndProp, flatten([payload.outputs])).join(', ') : output; @@ -398,9 +426,12 @@ async function fireReadyCallbacks(dispatch, getState, callbacks) { return hasClientSide ? fireNext().then(done) : done; } -function fillVals(paths, layout, cb, specs, depType) { +function fillVals(paths, layout, cb, specs, depType, allowAllMissing) { const getter = depType === 'Input' ? cb.getInputs : cb.getState; - return getter(paths).map((inputList, i) => + const errors = []; + let emptyMultiValues = 0; + + const fillInputs = (inputList, i) => unwrapIfNotMulti( paths, inputList.map(({id, property, path: path_}) => ({ @@ -411,8 +442,45 @@ function fillVals(paths, layout, cb, specs, depType) { specs[i], cb.anyVals, depType - ) - ); + ); + + const tryFill = (inputList, i) => { + try { + const inputs = fillInputs(inputList, i); + if (isMultiValued(specs[i]) && !inputs.length) { + emptyMultiValues++; + } + return inputs; + } catch (e) { + if (e instanceof ReferenceError) { + errors.push(e); + return null; + } + // any other error we still want to see! + throw e; + } + }; + + const inputVals = getter(paths).map(allowAllMissing ? tryFill : fillInputs); + + if (errors.length) { + if (errors.length + emptyMultiValues === inputVals.length) { + // We have at least one non-multivalued input, but all simple and + // multi-valued inputs are missing. + // (if all inputs are multivalued and all missing we still return + // them as normal, and fire the callback.) + return null; + } + // If we get here we have some missing and some present inputs. + // That's a real error, so rethrow the first missing error. + // Wildcard reference errors mention a list of wildcard specs logged + // TODO: unwrapped list of wildcard ids? + // eslint-disable-next-line no-console + console.log(paths.objs); + throw errors[0]; + } + + return inputVals; } function handleServerside(config, payload, hooks) { diff --git a/tests/integration/callbacks/test_missing_inputs.py b/tests/integration/callbacks/test_missing_inputs.py new file mode 100644 index 0000000000..2cfffb8ecc --- /dev/null +++ b/tests/integration/callbacks/test_missing_inputs.py @@ -0,0 +1,484 @@ +import json +import dash_html_components as html +import dash +from dash.testing import wait +from dash.dependencies import Input, Output, State, ALL, MATCH + + +def wait_for_queue(dash_duo): + # mostly for cases where no callbacks should fire: + # just wait until we have the button and the queue is empty + dash_duo.wait_for_text_to_equal("#btn", "click") + wait.until(lambda: dash_duo.redux_state_rqs == [], 3) + + +def test_cbmi001_all_missing_inputs(dash_duo): + app = dash.Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div( + [ + html.Div("Title", id="title"), + html.Button("click", id="btn"), + html.Div(id="content"), + html.Div("output1 init", id="out1"), + html.Div("output2 init", id="out2"), + html.Div("output3 init", id="out3"), + ] + ) + + @app.callback(Output("content", "children"), [Input("btn", "n_clicks")]) + def content(n): + return ( + [html.Div("A", id="a"), html.Div("B", id="b"), html.Div("C", id="c")] + if n + else "content init" + ) + + @app.callback( + Output("out1", "children"), + [Input("a", "children"), Input("b", "children")], + [State("c", "children"), State("title", "children")], + ) + def out1(a, b, c, title): + # title exists at the start but State isn't enough to fire the callback + assert c == "C" + assert title == "Title" + return a + b + + @app.callback( + Output("out2", "children"), + [Input("out1", "children")], + [State("title", "children")], + ) + def out2(out1, title): + return out1 + " - 2 - " + title + + @app.callback( + Output("out3", "children"), + [Input("out1", "children"), Input("title", "children")], + ) + def out3(out1, title): + return out1 + " - 3 - " + title + + dash_duo.start_server(app) + wait_for_queue(dash_duo) + + # out3 fires because it has another Input besides out1 + dash_duo.wait_for_text_to_equal("#out3", "output1 init - 3 - Title") + + assert dash_duo.find_element("#out1").text == "output1 init" + + # out2 doesn't fire because its only input (out1) is "prevented" + # State items don't matter for this. + assert dash_duo.find_element("#out2").text == "output2 init" + + dash_duo.find_element("#btn").click() + + # after a button click all inputs are present so all callbacks fire. + dash_duo.wait_for_text_to_equal("#out1", "AB") + dash_duo.wait_for_text_to_equal("#out2", "AB - 2 - Title") + dash_duo.wait_for_text_to_equal("#out3", "AB - 3 - Title") + + assert not dash_duo.get_logs() + + +def test_cbmi002_follow_on_to_two_skipped_callbacks(dash_duo): + app = dash.Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div( + [ + html.Button("click", id="btn"), + html.Div(id="content"), + html.Div("output1 init", id="out1"), + html.Div("output2 init", id="out2"), + html.Div("output3 init", id="out3"), + ] + ) + + @app.callback(Output("content", "children"), [Input("btn", "n_clicks")]) + def content(n): + return [html.Div("A", id="a"), html.Div("B", id="b")] if n else "content init" + + @app.callback(Output("out1", "children"), [Input("a", "children")]) + def out1(a): + return a + + @app.callback(Output("out2", "children"), [Input("b", "children")]) + def out2(b): + return b + + @app.callback( + Output("out3", "children"), + [Input("out1", "children"), Input("out2", "children")], + ) + def out3(out1, out2): + return out1 + out2 + + dash_duo.start_server(app) + wait_for_queue(dash_duo) + + for i in ["1", "2", "3"]: + assert dash_duo.find_element("#out" + i).text == "output{} init".format(i) + + dash_duo.find_element("#btn").click() + # now all callbacks fire + dash_duo.wait_for_text_to_equal("#out1", "A") + dash_duo.wait_for_text_to_equal("#out2", "B") + dash_duo.wait_for_text_to_equal("#out3", "AB") + + assert not dash_duo.get_logs() + + +def test_cbmi003_some_missing_inputs(dash_duo): + # this one is an error! + app = dash.Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div( + [ + html.Div("Title", id="title"), + html.Button("click", id="btn"), + html.Div(id="content"), + html.Div("output1 init", id="out1"), + html.Div("output2 init", id="out2"), + ] + ) + + @app.callback(Output("content", "children"), [Input("btn", "n_clicks")]) + def content(n): + return [html.Div("A", id="a"), html.Div("B", id="b")] if n else "content init" + + @app.callback( + Output("out1", "children"), [Input("a", "children"), Input("title", "children")] + ) + def out1(a, title): + return a + title + + @app.callback(Output("out2", "children"), [Input("out1", "children")]) + def out2(out1): + return out1 + " - 2" + + dash_duo.start_server(app) + wait_for_queue(dash_duo) + + dash_duo.wait_for_text_to_equal("#content", "content init") + + logs = json.dumps(dash_duo.get_logs()) + assert "ReferenceError" in logs + assert "The id of this object is `a` and the property is `children`." in logs + + # after the error we can still use the app - and no more errors + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out2", "ATitle - 2") + assert not dash_duo.get_logs() + + +def test_cbmi004_some_missing_outputs(dash_duo): + app = dash.Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div( + [ + html.Button("click", id="btn"), + html.Div(id="content"), + html.Div("output1 init", id="out1"), + ] + ) + + @app.callback(Output("content", "children"), [Input("btn", "n_clicks")]) + def content(n): + return [html.Div("A", id="a"), html.Div("B", id="b")] if n else "content init" + + @app.callback( + [Output("out1", "children"), Output("b", "children")], [Input("a", "children")] + ) + def out1(a): + return a, a + + dash_duo.start_server(app) + wait_for_queue(dash_duo) + + dash_duo.wait_for_text_to_equal("#content", "content init") + + assert dash_duo.find_element("#out1").text == "output1 init" + + dash_duo.find_element("#btn").click() + + # after a button click all inputs are present so all callbacks fire. + dash_duo.wait_for_text_to_equal("#out1", "A") + dash_duo.wait_for_text_to_equal("#b", "A") + + assert not dash_duo.get_logs() + + +def test_cbmi005_all_multi_wildcards_with_output(dash_duo): + # if all the inputs are multi wildcards, AND there's an output, + # we DO fire the callback + app = dash.Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div( + [ + html.Div("Title", id="title"), + html.Button("click", id="btn"), + html.Div(id="content"), + html.Div("output1 init", id="out1"), + html.Div("output2 init", id="out2"), + ] + ) + + @app.callback(Output("content", "children"), [Input("btn", "n_clicks")]) + def content(n): + out = [html.Div("item {}".format(i), id={"i": i}) for i in range(n or 0)] + return out or "content init" + + @app.callback(Output("out1", "children"), [Input({"i": ALL}, "children")]) + def out1(items): + return ", ".join(items) or "no items" + + @app.callback(Output("out2", "children"), [Input("out1", "children")]) + def out2(out1): + return out1 + " - 2" + + dash_duo.start_server(app) + wait_for_queue(dash_duo) + + dash_duo.wait_for_text_to_equal("#out2", "no items - 2") + + assert dash_duo.find_element("#out1").text == "no items" + assert dash_duo.find_element("#content").text == "content init" + + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out1", "item 0") + dash_duo.wait_for_text_to_equal("#out2", "item 0 - 2") + + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out1", "item 0, item 1") + dash_duo.wait_for_text_to_equal("#out2", "item 0, item 1 - 2") + + assert not dash_duo.get_logs() + + +def test_cbmi006_all_multi_wildcards_no_outputs(dash_duo): + # if all the inputs are multi wildcards, but there's NO output, + # we DO NOT fire the callback + app = dash.Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div( + [ + html.Div("Title", id="title"), + html.Button("click", id="btn"), + html.Div(id="content"), + html.Div("output2 init", id="out2"), + ] + ) + + @app.callback(Output("content", "children"), [Input("btn", "n_clicks")]) + def content(n): + out = [html.Div("item {}".format(i), id={"i": i}) for i in range(n or 0)] + return (out + [html.Div("output1 init", id="out1")]) if out else "content init" + + @app.callback(Output("out1", "children"), [Input({"i": ALL}, "children")]) + def out1(items): + return ", ".join(items) or "no items" + + @app.callback(Output("out2", "children"), [Input("out1", "children")]) + def out2(out1): + return out1 + " - 2" + + dash_duo.start_server(app) + wait_for_queue(dash_duo) + + dash_duo.wait_for_text_to_equal("#out2", "output2 init") + + assert dash_duo.find_element("#content").text == "content init" + + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out1", "item 0") + dash_duo.wait_for_text_to_equal("#out2", "item 0 - 2") + + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out1", "item 0, item 1") + dash_duo.wait_for_text_to_equal("#out2", "item 0, item 1 - 2") + + assert not dash_duo.get_logs() + + +def test_cbmi007_all_multi_wildcards_some_outputs(dash_duo): + # same as above (cbmi006) but multi-output, some outputs present some missing. + # Again we DO NOT fire the callback + app = dash.Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div( + [ + html.Div("Title", id="title"), + html.Button("click", id="btn"), + html.Div(id="content"), + html.Div("output2 init", id="out2"), + html.Div("output3 init", id="out3"), + ] + ) + + @app.callback(Output("content", "children"), [Input("btn", "n_clicks")]) + def content(n): + out = [html.Div("item {}".format(i), id={"i": i}) for i in range(n or 0)] + return (out + [html.Div("output1 init", id="out1")]) if out else "content init" + + @app.callback( + [Output("out1", "children"), Output("out3", "children")], + [Input({"i": ALL}, "children")], + ) + def out1(items): + out = ", ".join(items) or "no items" + return out, (out + " - 3") + + @app.callback(Output("out2", "children"), [Input("out1", "children")]) + def out2(out1): + return out1 + " - 2" + + dash_duo.start_server(app) + wait_for_queue(dash_duo) + + dash_duo.wait_for_text_to_equal("#out2", "output2 init") + dash_duo.wait_for_text_to_equal("#out3", "output3 init") + + assert dash_duo.find_element("#content").text == "content init" + + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out1", "item 0") + dash_duo.wait_for_text_to_equal("#out2", "item 0 - 2") + dash_duo.wait_for_text_to_equal("#out3", "item 0 - 3") + + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out1", "item 0, item 1") + dash_duo.wait_for_text_to_equal("#out2", "item 0, item 1 - 2") + dash_duo.wait_for_text_to_equal("#out3", "item 0, item 1 - 3") + + assert not dash_duo.get_logs() + + +def test_cbmi008_multi_wildcards_and_simple_all_missing(dash_duo): + # if only SOME of the inputs are multi wildcards, even with an output, + # we DO NOT fire the callback + app = dash.Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div( + [ + html.Div("Title", id="title"), + html.Button("click", id="btn"), + html.Div(id="content"), + html.Div("output1 init", id="out1"), + html.Div("output2 init", id="out2"), + ] + ) + + @app.callback(Output("content", "children"), [Input("btn", "n_clicks")]) + def content(n): + out = [html.Div("item {}".format(i), id={"i": i}) for i in range(n or 0)] + return (out + [html.Div("A", id="a")]) if out else "content init" + + @app.callback( + Output("out1", "children"), + [Input({"i": ALL}, "children"), Input("a", "children")], + ) + def out1(items, a): + return a + " - " + (", ".join(items) or "no items") + + @app.callback(Output("out2", "children"), [Input("out1", "children")]) + def out2(out1): + return out1 + " - 2" + + dash_duo.start_server(app) + wait_for_queue(dash_duo) + + dash_duo.wait_for_text_to_equal("#content", "content init") + + assert dash_duo.find_element("#out1").text == "output1 init" + assert dash_duo.find_element("#out2").text == "output2 init" + + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out1", "A - item 0") + dash_duo.wait_for_text_to_equal("#out2", "A - item 0 - 2") + + dash_duo.find_element("#btn").click() + dash_duo.wait_for_text_to_equal("#out1", "A - item 0, item 1") + dash_duo.wait_for_text_to_equal("#out2", "A - item 0, item 1 - 2") + + assert not dash_duo.get_logs() + + +def test_cbmi009_match_wildcards_all_missing(dash_duo): + # Kind of contrived - MATCH will always be 0. Just want to make sure + # that this behaves the same as cbmi001 + app = dash.Dash(__name__, suppress_callback_exceptions=True) + app.layout = html.Div( + [ + html.Div("Title", id={"i": 0, "id": "title"}), + html.Button("click", id="btn"), + html.Div(id="content"), + html.Div("output1 init", id={"i": 0, "id": "out1"}), + html.Div("output2 init", id={"i": 0, "id": "out2"}), + html.Div("output3 init", id={"i": 0, "id": "out3"}), + ] + ) + + @app.callback(Output("content", "children"), [Input("btn", "n_clicks")]) + def content(n): + return ( + [ + html.Div("A", id={"i": 0, "id": "a"}), + html.Div("B", id={"i": 0, "id": "b"}), + html.Div("C", id={"i": 0, "id": "c"}), + ] + if n + else "content init" + ) + + @app.callback( + Output({"i": MATCH, "id": "out1"}, "children"), + [ + Input({"i": MATCH, "id": "a"}, "children"), + Input({"i": MATCH, "id": "b"}, "children"), + ], + [ + State({"i": MATCH, "id": "c"}, "children"), + State({"i": MATCH, "id": "title"}, "children"), + ], + ) + def out1(a, b, c, title): + # title exists at the start but State isn't enough to fire the callback + assert c == "C" + assert title == "Title" + return a + b + + @app.callback( + Output({"i": MATCH, "id": "out2"}, "children"), + [Input({"i": MATCH, "id": "out1"}, "children")], + [State({"i": MATCH, "id": "title"}, "children")], + ) + def out2(out1, title): + return out1 + " - 2 - " + title + + @app.callback( + Output({"i": MATCH, "id": "out3"}, "children"), + [ + Input({"i": MATCH, "id": "out1"}, "children"), + Input({"i": MATCH, "id": "title"}, "children"), + ], + ) + def out3(out1, title): + return out1 + " - 3 - " + title + + dash_duo.start_server(app) + wait_for_queue(dash_duo) + + def cssid(v): + # escaped CSS for object IDs + return r"#\{\"i\"\:0\,\"id\"\:\"" + v + r"\"\}" + + # out3 fires because it has another Input besides out1 + dash_duo.wait_for_text_to_equal(cssid("out3"), "output1 init - 3 - Title") + + assert dash_duo.find_element(cssid("out1")).text == "output1 init" + + # out2 doesn't fire because its only input (out1) is "prevented" + # State items don't matter for this. + assert dash_duo.find_element(cssid("out2")).text == "output2 init" + + dash_duo.find_element("#btn").click() + + # after a button click all inputs are present so all callbacks fire. + dash_duo.wait_for_text_to_equal(cssid("out1"), "AB") + dash_duo.wait_for_text_to_equal(cssid("out2"), "AB - 2 - Title") + dash_duo.wait_for_text_to_equal(cssid("out3"), "AB - 3 - Title") + + assert not dash_duo.get_logs() diff --git a/tests/integration/devtools/test_callback_validation.py b/tests/integration/devtools/test_callback_validation.py index 423b534ed8..a006e23eec 100644 --- a/tests/integration/devtools/test_callback_validation.py +++ b/tests/integration/devtools/test_callback_validation.py @@ -423,15 +423,10 @@ def h(a): return app -# These ones are raised by bad_id_app whether suppressing callback exceptions or not +# This one is raised by bad_id_app whether suppressing callback exceptions or not +# yeah-no no longer raises an error on dispatch due to the no-input regression fix +# for issue #1200 dispatch_specs = [ - [ - "A nonexistent object was used in an `Input` of a Dash callback. " - "The id of this object is `yeah-no` and the property is `value`. " - "The string ids in the current layout are: " - "[main, outer-div, inner-div, inner-input, outer-input]", - [], - ], [ "A nonexistent object was used in an `Output` of a Dash callback. " "The id of this object is `nope` and the property is `children`. " From a2387c361d9e0524e9286d932143eb29ffee484d Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Sun, 26 Apr 2020 03:38:28 -0400 Subject: [PATCH 3/5] changelog for #1212 no-input callback regression fix --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7a012bff1..0cdfc8931b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - [#1078](https://github.com/plotly/dash/pull/1078) Permit usage of arbitrary file extensions for assets within component libraries ### Fixed +- [#1212](https://github.com/plotly/dash/pull/1212) Fixes [#1200](https://github.com/plotly/dash/issues/1200) - prior to Dash 1.11, if none of the inputs to a callback were on the page, it was not an error. This was, and is now again, treated as though the callback raised PreventUpdate. The one exception to this is with pattern-matching callbacks, when every Input uses a multi-value wildcard (ALL or ALLSMALLER), and every Output is on the page. In that case the callback fires as usual. - [#1201](https://github.com/plotly/dash/pull/1201) Fixes [#1193](https://github.com/plotly/dash/issues/1193) - prior to Dash 1.11, you could use `flask.has_request_context() == False` inside an `app.layout` function to provide a special layout containing all IDs for validation purposes in a multi-page app. Dash 1.11 broke this when we moved most of this validation into the renderer. This change makes it work again. ## [1.11.0] - 2020-04-10 From 4cdb7b017a718b0a249c8ed11a9d17664556b054 Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Mon, 27 Apr 2020 13:11:32 -0400 Subject: [PATCH 4/5] console.error when we're logging info related to an error we're about to throw --- dash-renderer/src/actions/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 239ba5cb77..5a961d786b 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -476,7 +476,7 @@ function fillVals(paths, layout, cb, specs, depType, allowAllMissing) { // Wildcard reference errors mention a list of wildcard specs logged // TODO: unwrapped list of wildcard ids? // eslint-disable-next-line no-console - console.log(paths.objs); + console.error(paths.objs); throw errors[0]; } From b44bd54a8cd6c2d3e0e89e73c2cbc190b499ec5a Mon Sep 17 00:00:00 2001 From: alexcjohnson Date: Tue, 28 Apr 2020 01:01:21 -0400 Subject: [PATCH 5/5] don't use errors as control flow --- dash-renderer/src/actions/index.js | 144 ++++++++++++++--------------- 1 file changed, 72 insertions(+), 72 deletions(-) diff --git a/dash-renderer/src/actions/index.js b/dash-renderer/src/actions/index.js index 5a961d786b..83972f4bcc 100644 --- a/dash-renderer/src/actions/index.js +++ b/dash-renderer/src/actions/index.js @@ -136,37 +136,33 @@ function moveHistory(changeType) { } function unwrapIfNotMulti(paths, idProps, spec, anyVals, depType) { + let msg = ''; + if (isMultiValued(spec)) { - return idProps; + return [idProps, msg]; } + if (idProps.length !== 1) { if (!idProps.length) { - if (typeof spec.id === 'string') { - throw new ReferenceError( - 'A nonexistent object was used in an `' + - depType + - '` of a Dash callback. The id of this object is `' + - spec.id + - '` and the property is `' + - spec.property + - '`. The string ids in the current layout are: [' + - keys(paths.strs).join(', ') + - ']' - ); - } - throw new ReferenceError( + const isStr = typeof spec.id === 'string'; + msg = 'A nonexistent object was used in an `' + - depType + - '` of a Dash callback. The id of this object is ' + - JSON.stringify(spec.id) + - (anyVals ? ' with MATCH values ' + anyVals : '') + - ' and the property is `' + - spec.property + - '`. The wildcard ids currently available are logged above.' - ); - } - throw new ReferenceError( - 'Multiple objects were found for an `' + + depType + + '` of a Dash callback. The id of this object is ' + + (isStr + ? '`' + spec.id + '`' + : JSON.stringify(spec.id) + + (anyVals ? ' with MATCH values ' + anyVals : '')) + + ' and the property is `' + + spec.property + + (isStr + ? '`. The string ids in the current layout are: [' + + keys(paths.strs).join(', ') + + ']' + : '`. The wildcard ids currently available are logged above.'); + } else { + msg = + 'Multiple objects were found for an `' + depType + '` of a callback that only takes one value. The id spec is ' + JSON.stringify(spec.id) + @@ -174,10 +170,10 @@ function unwrapIfNotMulti(paths, idProps, spec, anyVals, depType) { ' and the property is `' + spec.property + '`. The objects we found are: ' + - JSON.stringify(map(pick(['id', 'property']), idProps)) - ); + JSON.stringify(map(pick(['id', 'property']), idProps)); + } } - return idProps[0]; + return [idProps[0], msg]; } function startCallbacks(callbacks) { @@ -259,26 +255,30 @@ async function fireReadyCallbacks(dispatch, getState, callbacks) { return preventCallback(); } - let outputs; - try { - outputs = allOutputs.map((out, i) => - unwrapIfNotMulti( - paths, - map(pick(['id', 'property']), out), - cb.callback.outputs[i], - cb.anyVals, - 'Output' - ) + const outputs = []; + const outputErrors = []; + allOutputs.forEach((out, i) => { + const [outi, erri] = unwrapIfNotMulti( + paths, + map(pick(['id', 'property']), out), + cb.callback.outputs[i], + cb.anyVals, + 'Output' ); - } catch (e) { - if (e instanceof ReferenceError && !flatten(inVals).length) { - // This case is all-empty multivalued wildcard inputs, - // which we would normally fire the callback for, except - // some outputs are missing. So instead we treat it like - // regular missing inputs and just silently prevent it. - return preventCallback(); + outputs.push(outi); + if (erri) { + outputErrors.push(erri); } - throw e; + }); + if (outputErrors.length) { + if (flatten(inVals).length) { + refErr(outputErrors, paths); + } + // This case is all-empty multivalued wildcard inputs, + // which we would normally fire the callback for, except + // some outputs are missing. So instead we treat it like + // regular missing inputs and just silently prevent it. + return preventCallback(); } payload = { @@ -431,8 +431,8 @@ function fillVals(paths, layout, cb, specs, depType, allowAllMissing) { const errors = []; let emptyMultiValues = 0; - const fillInputs = (inputList, i) => - unwrapIfNotMulti( + const inputVals = getter(paths).map((inputList, i) => { + const [inputs, inputError] = unwrapIfNotMulti( paths, inputList.map(({id, property, path: path_}) => ({ id, @@ -443,28 +443,20 @@ function fillVals(paths, layout, cb, specs, depType, allowAllMissing) { cb.anyVals, depType ); - - const tryFill = (inputList, i) => { - try { - const inputs = fillInputs(inputList, i); - if (isMultiValued(specs[i]) && !inputs.length) { - emptyMultiValues++; - } - return inputs; - } catch (e) { - if (e instanceof ReferenceError) { - errors.push(e); - return null; - } - // any other error we still want to see! - throw e; + if (isMultiValued(specs[i]) && !inputs.length) { + emptyMultiValues++; } - }; - - const inputVals = getter(paths).map(allowAllMissing ? tryFill : fillInputs); + if (inputError) { + errors.push(inputError); + } + return inputs; + }); if (errors.length) { - if (errors.length + emptyMultiValues === inputVals.length) { + if ( + allowAllMissing && + errors.length + emptyMultiValues === inputVals.length + ) { // We have at least one non-multivalued input, but all simple and // multi-valued inputs are missing. // (if all inputs are multivalued and all missing we still return @@ -472,15 +464,23 @@ function fillVals(paths, layout, cb, specs, depType, allowAllMissing) { return null; } // If we get here we have some missing and some present inputs. - // That's a real error, so rethrow the first missing error. + // Or all missing in a context that doesn't allow this. + // That's a real problem, so throw the first message as an error. + refErr(errors, paths); + } + + return inputVals; +} + +function refErr(errors, paths) { + const err = errors[0]; + if (err.indexOf('logged above') !== -1) { // Wildcard reference errors mention a list of wildcard specs logged // TODO: unwrapped list of wildcard ids? // eslint-disable-next-line no-console console.error(paths.objs); - throw errors[0]; } - - return inputVals; + throw new ReferenceError(err); } function handleServerside(config, payload, hooks) {