Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue 1350 - Multi-input callback with sync event handling #1385

Merged
merged 28 commits into from
Sep 3, 2020

Conversation

Marc-Andre-Rivet
Copy link
Contributor

@Marc-Andre-Rivet Marc-Andre-Rivet commented Aug 27, 2020

Fixes #1350

…y the same user action are grouped

- merge duplicate callback props into a new callback if duplicates are found in `requestedCallbacks`
@Marc-Andre-Rivet Marc-Andre-Rivet marked this pull request as ready for review August 27, 2020 21:09
*/
const rMergedDuplicates = map(group => assoc(
'changedPropIds',
mergeAll(pluck('changedPropIds', group)),
Copy link
Collaborator

Choose a reason for hiding this comment

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

In order to ensure DIRECT doesn't turn into INDIRECT we need something like:

export const mergeMax = mergeWith(Math.max);

Copy link
Contributor Author

@Marc-Andre-Rivet Marc-Andre-Rivet Aug 28, 2020

Choose a reason for hiding this comment

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

@alexcjohnson Thanks for the heads up. Also distinguishing the initial callback from subsequent callbacks 8b6ff26

dispatch,
getState
}) => {
await wait(0);
Copy link
Contributor Author

@Marc-Andre-Rivet Marc-Andre-Rivet Aug 28, 2020

Choose a reason for hiding this comment

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

Adding this little wait apparently has wide-ranging consequences on the timing of various features. So far:

events.current.emit('rendered');
(async () => {
renderedTree.current = false;
await wait(0);
Copy link
Contributor Author

@Marc-Andre-Rivet Marc-Andre-Rivet Aug 31, 2020

Choose a reason for hiding this comment

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

I really dislike this artificial delay being required to counteract the additional processing delay in https://github.com/plotly/dash/pull/1385/files#diff-178b2bd05a773877fd1b117eef8b1e8cR63 but I can't think of a better alternative atm.

@@ -21,6 +21,7 @@ interface IStoreObserverState<TStore> {
export interface IStoreObserverDefinition<TStore> {
observer: Observer<Store<TStore>>;
inputs: string[]
[key: string]: any;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just some flexibility when creating observers

);
}

updateTitle(getState);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Replaces <DocumentTitle /> and makes it arguably simpler to handle store changes and DOM mutations in a consistent manner as it doesn't need to deal with component lifecycle anymore.

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 solution should be more robust as it can react to both the DOM changing and the store changing in ways that are not dependant on exact execution timing (the additional wait broke the test/behavior)

}, values(
groupBy<ICallback>(
getUniqueIdentifier,
requested
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Merge callbacks, keep correct changePropIds DIRECT/INDIRECT and last know executionGroup if there is one. Always drop the initial callback if there is one and there are duplicates.

@@ -677,7 +677,7 @@ def c2(children):

@app.callback([Output("a", "children")], [Input("c", "children")])
def c3(children):
return children
return (children,)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I assume that this is due to timing changes: The not a tuple error started appearing first instead of second and broke the test. Fixing it.

@Marc-Andre-Rivet Marc-Andre-Rivet changed the title [WIP] Issue 1350 - Multi-input callback with sync event handling Issue 1350 - Multi-input callback with sync event handling Aug 31, 2020
@alexcjohnson
Copy link
Collaborator

@Marc-Andre-Rivet this PR appears to MOSTLY fix the issue @bpostlethwaite encountered 🎉 🏆 🍹

Would you mind including the app below (and its corrected behavior) as a test case?

The premise: when prop A affects prop B, and both of them affect prop C, then the C callback should list both A and B as triggers. In the app below, there are in fact two primary inputs (A and A') affecting B, and the important thing - the reason we care about having both inputs appear as triggers - is for C to know whether it was A or A' that caused the callback to fire.

Ben's problem: only B was being listed as a trigger.

What I see on this branch:

  • On initial load, only B seems to be a trigger, when it should be all three: A, A', and B. But I bet Ben doesn't care about this case ;)
  • Later on, when the user changes A, both A and B appear as triggers 🎉

Here's Ben's app:

import dash
import dash_core_components as dcc
import dash_html_components as html
from dash.dependencies import Input, Output


app = dash.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",
            style={
                "border": "antiquewhite",
                "borderStyle": "dashed",
                "height": "200px",
                "width": "475px",
            },
        ),
    ]
)


@app.callback(
    Output("sum-number", "value"),
    [Input("input-number-1", "value"), Input("input-number-2", "value")],
)
def update_sum_number(n1, n2):
    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):
    ctx = dash.callback_context
    return [
        "{} + {} = {}".format(n1, n2, nsum),
        html.Br(),
        "ctx.triggered={}".format(ctx.triggered)
    ]


if __name__ == "__main__":
    app.run_server()

@Marc-Andre-Rivet
Copy link
Contributor Author

Marc-Andre-Rivet commented Sep 1, 2020

@alexcjohnson Dash version breakdown of callback context

Dash v1.5.0 to Dash v1.10.0 (pre PMC)
[{'prop_id': 'input-number-2.value', 'value': 0}]
[{'prop_id': 'sum-number.value', 'value': 0}]

Dash v1.11.0 and above
[{'prop_id': '.', 'value': None}]
[{'prop_id': 'sum-number.value', 'value': 0}]


After discussion, having initial callbacks without predecessors have no context feels like what was desired (triggered neither by user action of another callback). Callbacks with predecessors (e.g. sum-number.value above) will have triggers corresponding to the input props that got updated in previous callbacks. The Dash v1.11.0+ behavior will be locked in with a test and be considered the desired behavior.

@Marc-Andre-Rivet
Copy link
Contributor Author

@alexcjohnson Wondering why we override the "no trigger context" case with https://github.com/plotly/dash/blob/dev/dash/_callback_context.py#L31

@alexcjohnson
Copy link
Collaborator

After discussion, having initial callbacks without predecessors have no context feels like what was desired (triggered neither by user action of another callback).

Yep right, I was forgetting this behavior is exactly what we decided during the original callback refactor back in #1103 - so as of this PR we can consider Ben's issue completely resolved.

Wondering why we override the "no trigger context" case with https://github.com/plotly/dash/blob/dev/dash/_callback_context.py#L31

See this discussion #1103 (comment) and response #1103 (comment) for the backward compatibility concerns that prompted this.

def update(div1, div2, btn0, btn1, btn2):
global calls
global callback_contexts
global clicks
Copy link
Collaborator

Choose a reason for hiding this comment

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

Rather than globals, most of our tests use multiprocessing.Value for this type of situation - a little cleaner I think, and doesn't depend on threading vs processes when the server and selenium parts branch.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since callback_contexts is an array of dicts and clicks is a dict, I don't think I can easily use Value as it expects simple types. Instead used a scoped context class that should not leak outside of the test function. 90697b1

Copy link
Collaborator

Choose a reason for hiding this comment

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

Well ok, I won't insist - it's probably fine, since we should always be using threads. I still like the multiprocessing pattern better as a general rule though, just to make it crystal clear that you're using this to communicate between the server thread and the selenium thread.

Copy link
Collaborator

@alexcjohnson alexcjohnson left a comment

Choose a reason for hiding this comment

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

LGTM! Just one suggestion re: test patterns, everything else looks great!

@alexcjohnson
Copy link
Collaborator

This test-27 failure is interesting - seems plausible that one of the callback invocations was aborted before being requested because another keystroke came in first, which isn't a bad thing at all. Which means if we even want to keep this assertion maybe we just loosen it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[BUG] Concurrent callbacks broken in Dash 1.13+?
2 participants