Skip to content

Commit f54b7e9

Browse files
committed
fix #1105 - isAppReady with an unrendered but in-layout async component
1 parent 316cdd8 commit f54b7e9

File tree

6 files changed

+139
-48
lines changed

6 files changed

+139
-48
lines changed

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

+30-9
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {hydrateInitialOutputs, setGraphs, setPaths, setLayout} from './actions';
88
import {computePaths} from './actions/paths';
99
import {computeGraphs} from './actions/dependencies';
1010
import apiThunk from './actions/api';
11+
import {EventEmitter} from './actions/utils';
1112
import {applyPersistence} from './persistence';
1213
import {getAppState} from './reducers/constants';
1314
import {STATUS} from './constants/constants';
@@ -19,18 +20,29 @@ class UnconnectedContainer extends Component {
1920
constructor(props) {
2021
super(props);
2122
this.initialization = this.initialization.bind(this);
23+
this.emitReady = this.emitReady.bind(this);
2224
this.state = {
2325
errorLoading: false,
2426
};
27+
28+
// Event emitter to communicate when the DOM is ready
29+
this.events = new EventEmitter();
30+
// Flag to determine if we've really updated the dash components
31+
this.renderedTree = false;
2532
}
2633
componentDidMount() {
2734
this.initialization(this.props);
35+
this.emitReady();
2836
}
2937

3038
componentWillReceiveProps(props) {
3139
this.initialization(props);
3240
}
3341

42+
componentDidUpdate() {
43+
this.emitReady();
44+
}
45+
3446
initialization(props) {
3547
const {
3648
appLifecycle,
@@ -49,7 +61,9 @@ class UnconnectedContainer extends Component {
4961
layoutRequest.content,
5062
dispatch
5163
);
52-
dispatch(setPaths(computePaths(finalLayout, [])));
64+
dispatch(
65+
setPaths(computePaths(finalLayout, [], null, this.events))
66+
);
5367
dispatch(setLayout(finalLayout));
5468
}
5569
}
@@ -88,6 +102,13 @@ class UnconnectedContainer extends Component {
88102
}
89103
}
90104

105+
emitReady() {
106+
if (this.renderedTree) {
107+
this.renderedTree = false;
108+
this.events.emit('rendered');
109+
}
110+
}
111+
91112
render() {
92113
const {
93114
appLifecycle,
@@ -113,19 +134,19 @@ class UnconnectedContainer extends Component {
113134
<div className="_dash-error">Error loading dependencies</div>
114135
);
115136
} else if (appLifecycle === getAppState('HYDRATED')) {
116-
return config.ui === true ? (
117-
<GlobalErrorContainer>
118-
<TreeContainer
119-
_dashprivate_layout={layout}
120-
_dashprivate_path={[]}
121-
/>
122-
</GlobalErrorContainer>
123-
) : (
137+
this.renderedTree = true;
138+
139+
const tree = (
124140
<TreeContainer
125141
_dashprivate_layout={layout}
126142
_dashprivate_path={[]}
127143
/>
128144
);
145+
return config.ui === true ? (
146+
<GlobalErrorContainer>{tree}</GlobalErrorContainer>
147+
) : (
148+
tree
149+
);
129150
}
130151

131152
return <div className="_dash-loading">Loading...</div>;

Diff for: dash-renderer/src/TreeContainer.js

+13-11
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,7 @@ function CheckedComponent(p) {
8080
propTypeErrorHandler(errorMessage, props, type);
8181
}
8282

83-
return React.createElement(
84-
element,
85-
mergeRight(props, extraProps),
86-
...(Array.isArray(children) ? children : [children])
87-
);
83+
return createElement(element, props, extraProps, children);
8884
}
8985

9086
CheckedComponent.propTypes = {
@@ -95,6 +91,15 @@ CheckedComponent.propTypes = {
9591
extraProps: PropTypes.any,
9692
id: PropTypes.string,
9793
};
94+
95+
function createElement(element, props, extraProps, children) {
96+
const allProps = mergeRight(props, extraProps);
97+
if (Array.isArray(children)) {
98+
return React.createElement(element, allProps, ...children);
99+
}
100+
return React.createElement(element, allProps, children);
101+
}
102+
98103
class TreeContainer extends Component {
99104
constructor(props) {
100105
super(props);
@@ -188,6 +193,7 @@ class TreeContainer extends Component {
188193
// just the id we pass on to the rendered component
189194
props.id = stringifyId(props.id);
190195
}
196+
const extraProps = {loading_state, setProps};
191197

192198
return (
193199
<ComponentErrorBoundary
@@ -200,15 +206,11 @@ class TreeContainer extends Component {
200206
children={children}
201207
element={element}
202208
props={props}
203-
extraProps={{loading_state, setProps}}
209+
extraProps={extraProps}
204210
type={_dashprivate_layout.type}
205211
/>
206212
) : (
207-
React.createElement(
208-
element,
209-
mergeRight(props, {loading_state, setProps}),
210-
...(Array.isArray(children) ? children : [children])
211-
)
213+
createElement(element, props, extraProps, children)
212214
)}
213215
</ComponentErrorBoundary>
214216
);

Diff for: dash-renderer/src/actions/isAppReady.js

+17-1
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@ import {isReady} from '@plotly/dash-component-plugins';
33

44
import Registry from '../registry';
55
import {getPath} from './paths';
6+
import {stringifyId} from './dependencies';
67

78
export default (layout, paths, targets) => {
9+
if (!targets.length) {
10+
return true;
11+
}
812
const promises = [];
913

14+
const {events} = paths;
15+
const rendered = new Promise(resolveRendered => {
16+
events.once('rendered', resolveRendered);
17+
});
18+
1019
targets.forEach(id => {
1120
const pathOfId = getPath(paths, id);
1221
if (!pathOfId) {
@@ -22,7 +31,14 @@ export default (layout, paths, targets) => {
2231
const ready = isReady(component);
2332

2433
if (ready && typeof ready.then === 'function') {
25-
promises.push(ready);
34+
promises.push(
35+
Promise.race([
36+
ready,
37+
rendered.then(
38+
() => document.getElementById(stringifyId(id)) && ready
39+
),
40+
])
41+
);
2642
}
2743
});
2844

Diff for: dash-renderer/src/actions/paths.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {crawlLayout} from './utils';
2020
* values: array of values in the id, in order of keys
2121
*/
2222

23-
export function computePaths(subTree, startingPath, oldPaths) {
23+
export function computePaths(subTree, startingPath, oldPaths, events) {
2424
const {strs: oldStrs, objs: oldObjs} = oldPaths || {strs: {}, objs: {}};
2525

2626
const diffHead = path => startingPath.some((v, i) => path[i] !== v);
@@ -53,7 +53,9 @@ export function computePaths(subTree, startingPath, oldPaths) {
5353
}
5454
});
5555

56-
return {strs, objs};
56+
// We include an event emitter here because it will be used along with
57+
// paths to determine when the app is ready for callbacks.
58+
return {strs, objs, events: events || oldPaths.events};
5759
}
5860

5961
export function getPath(paths, id) {

Diff for: dash-renderer/src/actions/utils.js

+34
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,37 @@ export const crawlLayout = (object, func, currentPath = []) => {
4343
}
4444
}
4545
};
46+
47+
// There are packages for this but it's simple enough, I just
48+
// adapted it from https://gist.github.com/mudge/5830382
49+
export class EventEmitter {
50+
constructor() {
51+
this._ev = {};
52+
}
53+
on(event, listener) {
54+
const events = (this._ev[event] = this._ev[event] || []);
55+
events.push(listener);
56+
return () => this.removeListener(event, listener);
57+
}
58+
removeListener(event, listener) {
59+
const events = this._ev[event];
60+
if (events) {
61+
const idx = events.indexOf(listener);
62+
if (idx > -1) {
63+
events.splice(idx, 1);
64+
}
65+
}
66+
}
67+
emit(event, ...args) {
68+
const events = this._ev[event];
69+
if (events) {
70+
events.forEach(listener => listener.apply(this, args));
71+
}
72+
}
73+
once(event, listener) {
74+
const remove = this.on(event, (...args) => {
75+
remove();
76+
listener.apply(this, args);
77+
});
78+
}
79+
}

Diff for: tests/integration/callbacks/test_basic_callback.py

+41-25
Original file line numberDiff line numberDiff line change
@@ -177,47 +177,63 @@ def update_out(n_clicks):
177177
assert dash_duo.get_logs() == []
178178

179179

180-
@pytest.mark.skip(reason="https://github.com/plotly/dash/issues/1105")
181180
def test_cbsc004_callback_using_unloaded_async_component(dash_duo):
182181
app = dash.Dash()
183-
app.layout = html.Div(
184-
children=[
185-
dcc.Tabs(
186-
children=[
187-
dcc.Tab(
188-
children=[
189-
html.Button(id="btn", children="Update Input"),
190-
html.Div(id="output", children=["Hello"]),
191-
]
192-
),
193-
dcc.Tab(
194-
children=dash_table.DataTable(
195-
id="other-table",
196-
columns=[{"id": "a", "name": "A"}],
197-
data=[{"a": "b"}]
198-
)
199-
),
200-
]
201-
)
202-
]
203-
)
182+
app.layout = html.Div([
183+
dcc.Tabs([
184+
dcc.Tab("boo!"),
185+
dcc.Tab(
186+
dash_table.DataTable(
187+
id="table",
188+
columns=[{"id": "a", "name": "A"}],
189+
data=[{"a": "b"}]
190+
)
191+
),
192+
]),
193+
html.Button("Update Input", id="btn"),
194+
html.Div("Hello", id="output"),
195+
html.Div(id="output2")
196+
])
204197

205198
@app.callback(
206199
Output("output", "children"),
207200
[Input("btn", "n_clicks")],
208-
[State("other-table", "data")]
201+
[State("table", "data")]
209202
)
210203
def update_out(n_clicks, data):
211-
if n_clicks is None:
212-
return len(data)
204+
return json.dumps(data) + ' - ' + str(n_clicks)
213205

206+
@app.callback(
207+
Output("output2", "children"),
208+
[Input("btn", "n_clicks")],
209+
[State("table", "derived_viewport_data")]
210+
)
211+
def update_out2(n_clicks, data):
214212
return json.dumps(data) + ' - ' + str(n_clicks)
215213

216214
dash_duo.start_server(app)
217215

218216
dash_duo.wait_for_text_to_equal("#output", '[{"a": "b"}] - None')
217+
dash_duo.wait_for_text_to_equal("#output2", 'null - None')
218+
219219
dash_duo.find_element("#btn").click()
220220
dash_duo.wait_for_text_to_equal("#output", '[{"a": "b"}] - 1')
221+
dash_duo.wait_for_text_to_equal("#output2", 'null - 1')
222+
223+
dash_duo.find_element(".tab:not(.tab--selected)").click()
224+
dash_duo.wait_for_text_to_equal("#table th", "A")
225+
# table props are in state so no change yet
226+
dash_duo.wait_for_text_to_equal("#output2", 'null - 1')
227+
228+
# repeat a few times, since one of the failure modes I saw during dev was
229+
# intermittent - but predictably so?
230+
for i in range(2, 10):
231+
expected = '[{"a": "b"}] - ' + str(i)
232+
dash_duo.find_element("#btn").click()
233+
dash_duo.wait_for_text_to_equal("#output", expected)
234+
# now derived props are available
235+
dash_duo.wait_for_text_to_equal("#output2", expected)
236+
221237
assert dash_duo.get_logs() == []
222238

223239

0 commit comments

Comments
 (0)