Skip to content

Commit d87335b

Browse files
Issue 1350 - Multi-input callback with sync event handling (#1385)
1 parent 9268480 commit d87335b

13 files changed

+406
-96
lines changed

Diff for: CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ All notable changes to `dash` will be documented in this file.
33
This project adheres to [Semantic Versioning](https://semver.org/).
44

55
## [UNRELEASED]
6+
### Changed
7+
- [#1385](https://github.com/plotly/dash/pull/1385) Closes [#1350](https://github.com/plotly/dash/issues/1350) and fixes a previously undefined callback behavior when multiple elements are stacked on top of one another and their `n_clicks` props are used as inputs of the same callback. The callback will now trigger once with all the triggered `n_clicks` props changes.
8+
69
### Fixed
710
- [#1384](https://github.com/plotly/dash/pull/1384) Fixed a bug introduced by [#1180](https://github.com/plotly/dash/pull/1180) breaking use of `prevent_initial_call` as a positional arg in callback definitions
811

Diff for: dash-renderer/src/APIController.react.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {applyPersistence} from './persistence';
2020
import {getAppState} from './reducers/constants';
2121
import {STATUS} from './constants/constants';
2222
import {getLoadingState, getLoadingHash} from './utils/TreeContainer';
23+
import wait from './utils/wait';
2324

2425
export const DashContext = createContext({});
2526

@@ -63,8 +64,11 @@ const UnconnectedContainer = props => {
6364

6465
useEffect(() => {
6566
if (renderedTree.current) {
66-
renderedTree.current = false;
67-
events.current.emit('rendered');
67+
(async () => {
68+
renderedTree.current = false;
69+
await wait(0);
70+
events.current.emit('rendered');
71+
})();
6872
}
6973
});
7074

Diff for: dash-renderer/src/AppContainer.react.js

-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {connect} from 'react-redux';
22
import React from 'react';
33
import PropTypes from 'prop-types';
44
import APIController from './APIController.react';
5-
import DocumentTitle from './components/core/DocumentTitle.react';
65
import Loading from './components/core/Loading.react';
76
import Toolbar from './components/core/Toolbar.react';
87
import Reloader from './components/core/Reloader.react';
@@ -48,7 +47,6 @@ class UnconnectedAppContainer extends React.Component {
4847
<React.Fragment>
4948
{show_undo_redo ? <Toolbar /> : null}
5049
<APIController />
51-
<DocumentTitle />
5250
<Loading />
5351
<Reloader />
5452
</React.Fragment>

Diff for: dash-renderer/src/StoreObserver.ts

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface IStoreObserverState<TStore> {
2121
export interface IStoreObserverDefinition<TStore> {
2222
observer: Observer<Store<TStore>>;
2323
inputs: string[]
24+
[key: string]: any;
2425
}
2526

2627
export default class StoreObserver<TStore> {

Diff for: dash-renderer/src/components/core/DocumentTitle.react.js

-51
This file was deleted.

Diff for: dash-renderer/src/observers/documentTitle.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { IStoreObserverDefinition } from '../StoreObserver';
2+
import { IStoreState } from '../store';
3+
4+
const updateTitle = (getState: () => IStoreState) => {
5+
const {
6+
config,
7+
isLoading
8+
} = getState();
9+
10+
const update_title = config?.update_title;
11+
12+
if (!update_title) {
13+
return;
14+
}
15+
16+
if (isLoading) {
17+
if (document.title !== update_title) {
18+
observer.title = document.title;
19+
document.title = update_title;
20+
}
21+
} else {
22+
if (document.title === update_title) {
23+
document.title = observer.title;
24+
} else {
25+
observer.title = document.title;
26+
}
27+
}
28+
};
29+
30+
const observer: IStoreObserverDefinition<IStoreState> = {
31+
inputs: ['isLoading'],
32+
mutationObserver: undefined,
33+
observer: ({
34+
getState
35+
}) => {
36+
const {
37+
config
38+
} = getState();
39+
40+
if (observer.config !== config) {
41+
observer.config = config;
42+
observer.mutationObserver?.disconnect();
43+
observer.mutationObserver = new MutationObserver(() => updateTitle(getState));
44+
45+
const title = document.querySelector('title');
46+
if (title) {
47+
observer.mutationObserver.observe(
48+
title,
49+
{ subtree: true, childList: true, attributes: true, characterData: true }
50+
);
51+
}
52+
}
53+
54+
updateTitle(getState);
55+
}
56+
};
57+
58+
export default observer;

Diff for: dash-renderer/src/observers/requestedCallbacks.ts

+58-27
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,34 @@ import {
44
difference,
55
filter,
66
flatten,
7+
forEach,
78
groupBy,
89
includes,
910
intersection,
1011
isEmpty,
1112
isNil,
1213
map,
14+
mergeLeft,
15+
mergeWith,
16+
pluck,
17+
reduce,
1318
values
1419
} from 'ramda';
1520

1621
import { IStoreState } from '../store';
1722

1823
import {
1924
aggregateCallbacks,
20-
removeRequestedCallbacks,
2125
removePrioritizedCallbacks,
2226
removeExecutingCallbacks,
2327
removeWatchedCallbacks,
24-
addRequestedCallbacks,
2528
addPrioritizedCallbacks,
2629
addExecutingCallbacks,
2730
addWatchedCallbacks,
2831
removeBlockedCallbacks,
29-
addBlockedCallbacks
32+
addBlockedCallbacks,
33+
addRequestedCallbacks,
34+
removeRequestedCallbacks
3035
} from '../actions/callbacks';
3136

3237
import { isMultiValued } from '../actions/dependencies';
@@ -45,17 +50,23 @@ import {
4550
IBlockedCallback
4651
} from '../types/callbacks';
4752

53+
import wait from './../utils/wait';
54+
4855
import { getPendingCallbacks } from '../utils/callbacks';
4956
import { IStoreObserverDefinition } from '../StoreObserver';
5057

5158
const observer: IStoreObserverDefinition<IStoreState> = {
52-
observer: ({
59+
observer: async ({
5360
dispatch,
5461
getState
5562
}) => {
63+
await wait(0);
64+
5665
const { callbacks, callbacks: { prioritized, blocked, executing, watched, stored }, paths } = getState();
5766
let { callbacks: { requested } } = getState();
5867

68+
const initialRequested = requested.slice(0);
69+
5970
const pendingCallbacks = getPendingCallbacks(callbacks);
6071

6172
/*
@@ -78,17 +89,37 @@ const observer: IStoreObserverDefinition<IStoreState> = {
7889
1. Remove duplicated `requested` callbacks - give precedence to newer callbacks over older ones
7990
*/
8091

81-
/*
82-
Extract all but the first callback from each IOS-key group
83-
these callbacks are duplicates.
84-
*/
85-
const rDuplicates = flatten(map(
86-
group => group.slice(0, -1),
87-
values(
88-
groupBy<ICallback>(
89-
getUniqueIdentifier,
90-
requested
91-
)
92+
let rDuplicates: ICallback[] = [];
93+
let rMergedDuplicates: ICallback[] = [];
94+
95+
forEach(group => {
96+
if (group.length === 1) {
97+
// keep callback if its the only one of its kind
98+
rMergedDuplicates.push(group[0]);
99+
} else {
100+
const initial = group.find(cb => cb.initialCall);
101+
if (initial) {
102+
// drop the initial callback if it's not alone
103+
rDuplicates.push(initial);
104+
}
105+
106+
const groupWithoutInitial = group.filter(cb => cb !== initial);
107+
if (groupWithoutInitial.length === 1) {
108+
// if there's only one callback beside the initial one, keep that callback
109+
rMergedDuplicates.push(groupWithoutInitial[0]);
110+
} else {
111+
// otherwise merge all remaining callbacks together
112+
rDuplicates = concat(rDuplicates, groupWithoutInitial);
113+
rMergedDuplicates.push(mergeLeft({
114+
changedPropIds: reduce(mergeWith(Math.max), {}, pluck('changedPropIds', groupWithoutInitial)),
115+
executionGroup: filter(exg => !!exg, pluck('executionGroup', groupWithoutInitial)).slice(-1)[0]
116+
}, groupWithoutInitial.slice(-1)[0]) as ICallback);
117+
}
118+
}
119+
}, values(
120+
groupBy<ICallback>(
121+
getUniqueIdentifier,
122+
requested
92123
)
93124
));
94125

@@ -97,7 +128,7 @@ const observer: IStoreObserverDefinition<IStoreState> = {
97128
Clean up the `requested` list - during the dispatch phase,
98129
duplicates will be removed for real
99130
*/
100-
requested = difference(requested, rDuplicates);
131+
requested = rMergedDuplicates;
101132

102133
/*
103134
2. Remove duplicated `prioritized`, `executing` and `watching` callbacks
@@ -312,16 +343,24 @@ const observer: IStoreObserverDefinition<IStoreState> = {
312343
dropped
313344
);
314345

346+
requested = difference(
347+
requested,
348+
readyCallbacks
349+
);
350+
351+
const added = difference(requested, initialRequested);
352+
const removed = difference(initialRequested, requested);
353+
315354
dispatch(aggregateCallbacks([
355+
// Clean up requested callbacks
356+
added.length ? addRequestedCallbacks(added) : null,
357+
removed.length ? removeRequestedCallbacks(removed) : null,
316358
// Clean up duplicated callbacks
317-
rDuplicates.length ? removeRequestedCallbacks(rDuplicates) : null,
318359
pDuplicates.length ? removePrioritizedCallbacks(pDuplicates) : null,
319360
bDuplicates.length ? removeBlockedCallbacks(bDuplicates) : null,
320361
eDuplicates.length ? removeExecutingCallbacks(eDuplicates) : null,
321362
wDuplicates.length ? removeWatchedCallbacks(wDuplicates) : null,
322363
// Prune callbacks
323-
rRemoved.length ? removeRequestedCallbacks(rRemoved) : null,
324-
rAdded.length ? addRequestedCallbacks(rAdded) : null,
325364
pRemoved.length ? removePrioritizedCallbacks(pRemoved) : null,
326365
pAdded.length ? addPrioritizedCallbacks(pAdded) : null,
327366
bRemoved.length ? removeBlockedCallbacks(bRemoved) : null,
@@ -330,15 +369,7 @@ const observer: IStoreObserverDefinition<IStoreState> = {
330369
eAdded.length ? addExecutingCallbacks(eAdded) : null,
331370
wRemoved.length ? removeWatchedCallbacks(wRemoved) : null,
332371
wAdded.length ? addWatchedCallbacks(wAdded) : null,
333-
// Prune circular callbacks
334-
rCirculars.length ? removeRequestedCallbacks(rCirculars) : null,
335-
// Prune circular assumptions
336-
oldBlocked.length ? removeRequestedCallbacks(oldBlocked) : null,
337-
newBlocked.length ? addRequestedCallbacks(newBlocked) : null,
338-
// Drop non-triggered initial callbacks
339-
dropped.length ? removeRequestedCallbacks(dropped) : null,
340372
// Promote callbacks
341-
readyCallbacks.length ? removeRequestedCallbacks(readyCallbacks) : null,
342373
readyCallbacks.length ? addPrioritizedCallbacks(readyCallbacks) : null
343374
]));
344375
},

Diff for: dash-renderer/src/store.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ICallbacksState } from './reducers/callbacks';
77
import { LoadingMapState } from './reducers/loadingMap';
88
import { IsLoadingState } from './reducers/isLoading';
99

10+
import documentTitle from './observers/documentTitle';
1011
import executedCallbacks from './observers/executedCallbacks';
1112
import executingCallbacks from './observers/executingCallbacks';
1213
import isLoading from './observers/isLoading'
@@ -33,6 +34,7 @@ const storeObserver = new StoreObserver<IStoreState>();
3334
const setObservers = once(() => {
3435
const observe = storeObserver.observe;
3536

37+
observe(documentTitle);
3638
observe(isLoading);
3739
observe(loadingMap);
3840
observe(requestedCallbacks);

Diff for: dash-renderer/src/utils/wait.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default async (duration: number) => {
2+
let _resolve: any;
3+
const p = new Promise(resolve => _resolve = resolve);
4+
5+
setTimeout(_resolve, duration);
6+
7+
return p;
8+
}

0 commit comments

Comments
 (0)