Apply pending updates in order of priority
The queue maintains a pointer to the last progressed update in the list. Updates that come after that pointer are pending. The pointer is set to the end of the list during reconciliation. Pending updates are sorted by priority then insertion. Progressed updates are sorted by the order in which they were applied during reconciliation, which may not be by priority: if a component bails out before the updates are committed, in the next render, the progressed updates are applied in the same order that they were previously, even if a higher priority update comes in. Once a progressed update is flushed/committed, it's removed from the queue.
Andrew Clark committed
Dec 11, 2016 at 13:13 UTC
20f00045d323475251fec8d9f581d9ce6dc3fe91
3 files changed
+426
-59
scripts/fiber/tests-passing.txt
+8
@@ -1230,6 +1230,14 @@ src/renderers/shared/fiber/__tests__/ReactIncrementalSideEffects-test.js
1230
* invokes ref callbacks after insertion/update/unmount
1231
* supports string refs
1232
1233
+src/renderers/shared/fiber/__tests__/ReactIncrementalUpdates-test.js
1234
+* applies updates in order of priority
1235
+* applies updates with equal priority in insertion order
1236
+* only drops updates with equal or lesser priority when replaceState is called
1237
+* can abort an update, schedule additional updates, and resume
1238
+* can abort an update, schedule a replaceState, and resume
1239
+* does not call callbacks that are scheduled by another callback until a later commit
1240
+
1241
src/renderers/shared/fiber/__tests__/ReactTopLevelFragment-test.js
1242
* should render a simple fragment at the top of a component
1243
* should preserve state when switching from a single child
src/renderers/shared/fiber/ReactFiberUpdateQueue.js
+170
-59
@@ -20,7 +20,11 @@ const {
20
Callback: CallbackEffect,
21
} = require('ReactTypeOfSideEffect');
22
23
-const { NoWork } = require('ReactPriorityLevel');
23
+const {
24
+ NoWork,
25
+ SynchronousPriority,
26
+ TaskPriority,
27
+} = require('ReactPriorityLevel');
28
29
type PartialState<State, Props> =
30
$Subtype<State> |
@@ -37,22 +41,57 @@ type Update = {
41
next: Update | null,
42
};
43
44
+// Linked-list of updates
45
+//
46
+// - A "pending" update is one that has been scheduled but not yet used
47
+// for reconciliation.
48
+// - A "progressed" update is an update that was used for reconciliation, but
49
+// has not yet been flushed.
50
+//
51
+// The queue maintains a pointer to the last progressed update in the list.
52
+// Updates that come after that pointer are pending. The pointer is set to the
53
+// end of the list during reconciliation.
54
+//
55
+// Pending updates are sorted by priority then insertion. Progressed updates
56
+// are sorted by the order in which they were applied during reconciliation,
57
+// which may not be by priority: if a component bails out before the updates are
58
+// committed, in the next render, the progressed updates are applied in the same
59
+// order that they were previously, even if a higher priority update comes in.
60
+//
61
+// Once a progressed update is flushed/committed, it's removed from the queue.
62
export type UpdateQueue = {
41
- // Points to the first update.
63
first: Update | null,
64
+ // A pointer to the last progressed update in the queue. This may be null
65
+ // even in a non-empty queue, if all the updates are pending.
66
lastProgressedUpdate: Update | null,
44
- // Points to the last update.
67
last: Update | null,
68
};
69
48
-function getFirstPendingUpdate(queue : UpdateQueue) {
70
+function comparePriority(a : PriorityLevel, b : PriorityLevel) : number {
71
+ // When comparing update priorities, treat sync and Task work as equal.
72
+ // TODO: Could we avoid the need for this by always coercing sync priority
73
+ // to Task when scheduling an update?
74
+ if ((a === TaskPriority || a === SynchronousPriority) &&
75
+ (b === TaskPriority || b === SynchronousPriority)) {
76
+ return 0;
77
+ }
78
+ if (a === NoWork && b !== NoWork) {
79
+ return -Infinity;
80
+ }
81
+ if (a !== NoWork && b === NoWork) {
82
+ return Infinity;
83
+ }
84
+ return a - b;
85
+}
86
+
87
+function getFirstPendingUpdate(queue : UpdateQueue) : Update | null {
88
if (queue.lastProgressedUpdate) {
89
return queue.lastProgressedUpdate.next;
90
}
91
return queue.first;
92
}
93
55
-function getFirstProgressedUpdate(queue : UpdateQueue) {
94
+function getFirstProgressedUpdate(queue : UpdateQueue) : Update | null {
95
if (queue.lastProgressedUpdate) {
96
return queue.first;
97
}
@@ -60,8 +99,12 @@ function getFirstProgressedUpdate(queue : UpdateQueue) {
99
}
100
101
function hasPendingUpdate(queue : UpdateQueue, priorityLevel : PriorityLevel) : boolean {
63
- // TODO: Check priority level
64
- return Boolean(getFirstPendingUpdate(queue));
102
+ const firstPendingUpdate = getFirstPendingUpdate(queue);
103
+ if (!firstPendingUpdate) {
104
+ return false;
105
+ }
106
+ // Return true if the first pending update has greater or equal priority.
107
+ return comparePriority(firstPendingUpdate.priorityLevel, priorityLevel) <= 0;
108
}
109
exports.hasPendingUpdate = hasPendingUpdate;
110
@@ -72,7 +115,7 @@ function ensureUpdateQueue(fiber : Fiber) : UpdateQueue {
115
// We already have an update queue.
116
return fiber.updateQueue;
117
}
75
- const queue = {
118
+ const queue : UpdateQueue = {
119
first: null,
120
lastProgressedUpdate: null,
121
last: null,
@@ -87,20 +130,61 @@ function ensureUpdateQueue(fiber : Fiber) : UpdateQueue {
130
}
131
exports.ensureUpdateQueue = ensureUpdateQueue;
132
90
-function insertUpdateIntoQueue(queue : UpdateQueue, update : Update, priorityLevel : PriorityLevel) : void {
133
+function insertUpdateIntoQueue(queue : UpdateQueue, update : Update) : void {
134
// Add a pending update to the end of the queue.
92
- // TODO: Insert updates in order of priority.
135
if (!queue.last) {
136
// The queue is empty.
137
queue.first = queue.last = update;
96
- } else {
97
- // The queue is not empty. Append the update to the end.
138
+ return;
139
+ }
140
+ // The queue is not empty. Insert the new update into the queue, sorted by
141
+ // priority then insertion order.
142
+ const firstPendingUpdate = getFirstPendingUpdate(queue);
143
+ if (!firstPendingUpdate && queue.last) {
144
+ // This is the first pending update. Add it to the end of the queue.
145
queue.last.next = update;
146
queue.last = update;
100
- if (queue.lastProgressedUpdate && !queue.lastProgressedUpdate.next) {
101
- queue.lastProgressedUpdate.next = update;
147
+ return;
148
+ }
149
+
150
+ const priorityLevel = update.priorityLevel;
151
+ const lastPendingUpdate = queue.last;
152
+
153
+ let insertAfter;
154
+ let insertBefore;
155
+ if (queue.last && comparePriority(queue.last.priorityLevel, priorityLevel) <= 0) {
156
+ // Fast path where the incoming update has equal or lower priority than the
157
+ // last pending update. We can just append it to the end of the queue.
158
+ insertAfter = lastPendingUpdate;
159
+ insertBefore = null;
160
+ } else {
161
+ // Loop through the pending updates to find the first one with lower
162
+ // priority than the incoming update. Insert the incoming update before
163
+ // that one.
164
+ insertAfter = queue.lastProgressedUpdate;
165
+ insertBefore = firstPendingUpdate;
166
+ while (
167
+ insertBefore &&
168
+ comparePriority(insertBefore.priorityLevel, priorityLevel) <= 0
169
+ ) {
170
+ insertAfter = insertBefore;
171
+ insertBefore = insertBefore.next;
172
}
173
}
174
+
175
+ if (insertAfter) {
176
+ insertAfter.next = update;
177
+ } else {
178
+ // This is the first item in the queue.
179
+ queue.first = update;
180
+ }
181
+
182
+ if (insertBefore) {
183
+ update.next = insertBefore;
184
+ } else {
185
+ // This is the last item in the queue.
186
+ queue.last = update;
187
+ }
188
}
189
190
function addUpdate(
@@ -108,7 +192,7 @@ function addUpdate(
192
partialState : PartialState<any, any> | null,
193
priorityLevel : PriorityLevel
194
) : void {
111
- const update = {
195
+ const update : Update = {
196
priorityLevel,
197
partialState,
198
callback: null,
@@ -116,7 +200,7 @@ function addUpdate(
200
isForced: false,
201
next: null,
202
};
119
- insertUpdateIntoQueue(queue, update, priorityLevel);
203
+ insertUpdateIntoQueue(queue, update);
204
}
205
exports.addUpdate = addUpdate;
206
@@ -125,7 +209,7 @@ function addReplaceUpdate(
209
state : any | null,
210
priorityLevel : PriorityLevel
211
) : void {
128
- const replaceUpdate = {
212
+ const update : Update = {
213
priorityLevel,
214
partialState: state,
215
callback: null,
@@ -133,29 +217,57 @@ function addReplaceUpdate(
217
isForced: false,
218
next: null,
219
};
136
-
220
+ // Add a pending update to the end of the queue.
221
if (!queue.last) {
222
// The queue is empty.
139
- queue.first = queue.last = replaceUpdate;
140
- } else {
141
- // The queue is not empty.
223
+ queue.first = queue.last = update;
224
+ return;
225
+ }
226
+ // The queue is not empty. Insert the new update into the queue, sorted by
227
+ // priority then insertion order. Since this is a replace, drop all pending
228
+ // updates with equal priority. We can't drop updates with higher priority,
229
+ // because they might be flushed in an earlier commit. We'll drop them during
230
+ // the commit phase if necessary.
231
+ const firstPendingUpdate = getFirstPendingUpdate(queue);
232
+ if (!firstPendingUpdate && queue.last) {
233
+ // This is the first pending update. Add it to the end of the queue.
234
+ queue.last.next = update;
235
+ queue.last = update;
236
+ return;
237
+ }
238
143
- // Drop all existing pending updates.
144
- // TODO: Only drop updates with matching priority.
145
- if (queue.lastProgressedUpdate) {
146
- queue.lastProgressedUpdate.next = replaceUpdate;
147
- queue.last = replaceUpdate;
148
- } else {
149
- // Drop everything
150
- queue.first = queue.last = replaceUpdate;
239
+ // Find the last pending update with equal priority.
240
+ let replaceAfter = queue.lastProgressedUpdate;
241
+ let replaceBefore = firstPendingUpdate;
242
+ if (replaceBefore) {
243
+ let comparison = Infinity;
244
+ while (replaceBefore &&
245
+ (comparison = comparePriority(replaceBefore.priorityLevel, priorityLevel)) <= 0) {
246
+ if (comparison < 0) {
247
+ replaceAfter = replaceBefore;
248
+ }
249
+ replaceBefore = replaceBefore.next;
250
}
251
}
252
253
+ if (replaceAfter) {
254
+ replaceAfter.next = update;
255
+ } else {
256
+ // This is the first item in the queue.
257
+ queue.first = update;
258
+ }
259
+
260
+ if (replaceBefore) {
261
+ update.next = replaceBefore;
262
+ } else {
263
+ // This is the last item in the queue.
264
+ queue.last = update;
265
+ }
266
}
267
exports.addReplaceUpdate = addReplaceUpdate;
268
269
function addForceUpdate(queue : UpdateQueue, priorityLevel : PriorityLevel) : void {
158
- const update = {
270
+ const update : Update = {
271
priorityLevel,
272
partialState: null,
273
callback: null,
@@ -163,21 +275,24 @@ function addForceUpdate(queue : UpdateQueue, priorityLevel : PriorityLevel) : vo
275
isForced: true,
276
next: null,
277
};
166
- insertUpdateIntoQueue(queue, update, priorityLevel);
278
+ insertUpdateIntoQueue(queue, update);
279
}
280
exports.addForceUpdate = addForceUpdate;
281
282
283
function addCallback(queue : UpdateQueue, callback: Callback, priorityLevel : PriorityLevel) : void {
172
- if (getFirstPendingUpdate(queue) && queue.last && !queue.last.callback) {
284
+ if (getFirstPendingUpdate(queue) &&
285
+ queue.last &&
286
+ (queue.last.priorityLevel === priorityLevel) &&
287
+ !queue.last.callback) {
288
// If pending updates already exist, and the last pending update does not
174
- // have a callback, we can add the new callback to that update.
175
- // TODO: Add an additional check to ensure the priority matches.
289
+ // have a callback, and the priority levels are equal, we can add the
290
+ // incoming callback to that update to avoid an extra allocation.
291
queue.last.callback = callback;
292
return;
293
}
294
180
- const update = {
295
+ const update : Update = {
296
priorityLevel,
297
partialState: null,
298
callback,
@@ -185,25 +300,13 @@ function addCallback(queue : UpdateQueue, callback: Callback, priorityLevel : Pr
300
isForced: false,
301
next: null,
302
};
188
- insertUpdateIntoQueue(queue, update, priorityLevel);
303
+ insertUpdateIntoQueue(queue, update);
304
}
305
exports.addCallback = addCallback;
306
307
function getPendingPriority(queue : UpdateQueue) : PriorityLevel {
193
- // Loop through the pending updates to recompute the pending priority.
194
- // TODO: Once updates are sorted, just read from the first pending update.
195
- let priorityLevel = NoWork;
196
- // Start with first pending update
197
- let update = getFirstPendingUpdate(queue);
198
- while (update) {
199
- if (priorityLevel === NoWork ||
200
- priorityLevel >= update.priorityLevel) {
201
- // Update pending priority
202
- priorityLevel = update.priorityLevel;
203
- }
204
- update = update.next;
205
- }
206
- return priorityLevel;
308
+ const firstPendingUpdate = getFirstPendingUpdate(queue);
309
+ return firstPendingUpdate ? firstPendingUpdate.priorityLevel : NoWork;
310
}
311
exports.getPendingPriority = getPendingPriority;
312
@@ -225,23 +328,25 @@ function beginUpdateQueue(
328
props : any,
329
priorityLevel : PriorityLevel
330
) : any {
228
- // This merges the entire update queue into a single object, not just the
229
- // pending updates, because the previous state and props may have changed.
230
- // TODO: Would memoization be worth it?
331
+ // Applies updates with matching priority to the previous state to create
332
+ // a new state object. If an update was used previously but never flushed
333
+ // due to a bail out, it's used again regardless of its priority.
334
335
// Reset these flags. We'll update them while looping through the queue.
336
workInProgress.effectTag &= ~ForceUpdate;
337
workInProgress.effectTag &= ~CallbackEffect;
338
339
+ const prevLastProgressedUpdate = queue.lastProgressedUpdate;
340
let state = prevState;
341
let dontMutatePrevState = true;
342
let isEmpty = true;
239
-
240
- // TODO: Stop merging once we reach an update whose priority doesn't match.
241
- // Should this also apply to updates that were previous merged but bailed out?
242
- let update : Update | null = queue.first;
343
+ let alreadyProgressedUpdate = Boolean(prevLastProgressedUpdate);
344
let lastProgressedUpdate = null;
244
- while (update) {
345
+ let update : Update | null = queue.first;
346
+ while (update && (
347
+ alreadyProgressedUpdate ||
348
+ comparePriority(update.priorityLevel, priorityLevel) <= 0
349
+ )) {
350
let partialState;
351
if (update.isReplace) {
352
// A replace should drop all previous updates in the queue, so
@@ -267,10 +372,15 @@ function beginUpdateQueue(
372
if (update.callback) {
373
workInProgress.effectTag |= CallbackEffect;
374
}
375
+ if (update === prevLastProgressedUpdate) {
376
+ alreadyProgressedUpdate = false;
377
+ }
378
lastProgressedUpdate = update;
379
update = update.next;
380
}
381
382
+
383
+ // Mark the point in the queue where we stopped applying updates
384
queue.lastProgressedUpdate = lastProgressedUpdate;
385
386
if (isEmpty) {
@@ -296,8 +406,9 @@ function commitUpdateQueue(finishedWork : Fiber, queue : UpdateQueue, context :
406
}
407
}
408
299
- // Drop all completed updates, leaving only the pending updates.
409
+ // Drop all comitted updates, leaving only the pending updates.
410
queue.first = getFirstPendingUpdate(queue);
411
+ queue.lastProgressedUpdate = null;
412
if (!queue.first) {
413
queue.last = queue.lastProgressedUpdate = null;
414
src/renderers/shared/fiber/__tests__/ReactIncrementalUpdates-test.js
new
+248
@@ -0,0 +1,248 @@
1
+/**
2
+ * Copyright 2013-present, Facebook, Inc.
3
+ * All rights reserved.
4
+ *
5
+ * This source code is licensed under the BSD-style license found in the
6
+ * LICENSE file in the root directory of this source tree. An additional grant
7
+ * of patent rights can be found in the PATENTS file in the same directory.
8
+ *
9
+ * @emails react-core
10
+ */
11
+
12
+'use strict';
13
+
14
+var React;
15
+var ReactNoop;
16
+
17
+describe('ReactIncrementalUpdates', () => {
18
+ beforeEach(() => {
19
+ jest.resetModuleRegistry();
20
+ React = require('React');
21
+ ReactNoop = require('ReactNoop');
22
+ });
23
+
24
+ function div(...children) {
25
+ children = children.map(c => typeof c === 'string' ? { text: c } : c);
26
+ return { type: 'div', children, prop: undefined };
27
+ }
28
+
29
+ function span(prop) {
30
+ return { type: 'span', children: [], prop };
31
+ }
32
+
33
+ it('applies updates in order of priority', () => {
34
+ let state;
35
+ class Foo extends React.Component {
36
+ state = {};
37
+ componentDidMount() {
38
+ ReactNoop.performAnimationWork(() => {
39
+ // Has Animation priority
40
+ this.setState({ b: 'b' });
41
+ this.setState({ c: 'c' });
42
+ });
43
+ // Has Task priority
44
+ this.setState({ a: 'a' });
45
+ }
46
+ render() {
47
+ state = this.state;
48
+ return <div />;
49
+ }
50
+ }
51
+
52
+ ReactNoop.render(<Foo />);
53
+ ReactNoop.flush();
54
+ expect(Object.keys(state)).toEqual(['a', 'b', 'c']);
55
+ });
56
+
57
+ it('applies updates with equal priority in insertion order', () => {
58
+ let state;
59
+ class Foo extends React.Component {
60
+ state = {};
61
+ componentDidMount() {
62
+ // All have Task priority
63
+ this.setState({ a: 'a' });
64
+ this.setState({ b: 'b' });
65
+ this.setState({ c: 'c' });
66
+ }
67
+ render() {
68
+ state = this.state;
69
+ return <div />;
70
+ }
71
+ }
72
+
73
+ ReactNoop.render(<Foo />);
74
+ ReactNoop.flush();
75
+ expect(Object.keys(state)).toEqual(['a', 'b', 'c']);
76
+ });
77
+
78
+ it('only drops updates with equal or lesser priority when replaceState is called', () => {
79
+ let instance;
80
+ const Foo = React.createClass({
81
+ getInitialState() {
82
+ return {};
83
+ },
84
+ render() {
85
+ instance = this;
86
+ return (
87
+ <span prop={Object.keys(this.state).join('')} />
88
+ );
89
+ },
90
+ });
91
+
92
+ ReactNoop.render(<Foo />);
93
+ ReactNoop.flush();
94
+
95
+ instance.setState({ x: 'x' });
96
+ instance.setState({ y: 'y' });
97
+ ReactNoop.performAnimationWork(() => {
98
+ instance.setState({ a: 'a' });
99
+ instance.setState({ b: 'b' });
100
+ });
101
+ instance.replaceState({ c: 'c' });
102
+ instance.setState({ d: 'd' });
103
+
104
+ ReactNoop.flushAnimationPri();
105
+ // Even though a replaceState has been already scheduled, it hasn't been
106
+ // flushed yet because it has low priority.
107
+ expect(ReactNoop.getChildren()).toEqual([span('ab')]);
108
+
109
+ ReactNoop.flush();
110
+ // Now the rest of the updates are flushed.
111
+ expect(ReactNoop.getChildren()).toEqual([span('cd')]);
112
+ });
113
+
114
+ it('can abort an update, schedule additional updates, and resume', () => {
115
+ let instance;
116
+ class Foo extends React.Component {
117
+ state = {};
118
+ render() {
119
+ instance = this;
120
+ return (
121
+ <span prop={Object.keys(this.state).join('')} />
122
+ );
123
+ }
124
+ }
125
+
126
+ ReactNoop.render(<Foo />);
127
+ ReactNoop.flush();
128
+
129
+ let progressedUpdates = [];
130
+ function createUpdate(letter) {
131
+ return () => {
132
+ progressedUpdates.push(letter);
133
+ return {
134
+ [letter]: letter,
135
+ };
136
+ };
137
+ }
138
+
139
+ instance.setState(createUpdate('a'));
140
+ instance.setState(createUpdate('b'));
141
+ instance.setState(createUpdate('c'));
142
+
143
+ // Do just enough work to begin the update but not enough to flush it
144
+ ReactNoop.flushDeferredPri(20);
145
+ expect(ReactNoop.getChildren()).toEqual([span('')]);
146
+ expect(progressedUpdates).toEqual(['a', 'b', 'c']);
147
+
148
+ instance.setState(createUpdate('f'));
149
+ ReactNoop.performAnimationWork(() => {
150
+ instance.setState(createUpdate('d'));
151
+ instance.setState(createUpdate('e'));
152
+ });
153
+ instance.setState(createUpdate('g'));
154
+
155
+ // Updates a, b, and c were aborted, so they should be applied first even
156
+ // though they have low priority. Update f was scheduled after the render
157
+ // was aborted, so it should come after d and e, which have higher priority.
158
+ ReactNoop.flush();
159
+ expect(ReactNoop.getChildren()).toEqual([span('abcdefg')]);
160
+ expect(progressedUpdates).toEqual(['a', 'b', 'c', 'd', 'e', 'f', 'g']);
161
+ });
162
+
163
+ it('can abort an update, schedule a replaceState, and resume', () => {
164
+ let instance;
165
+ const Foo = React.createClass({
166
+ getInitialState() {
167
+ return {};
168
+ },
169
+ render() {
170
+ instance = this;
171
+ return (
172
+ <span prop={Object.keys(this.state).join('')} />
173
+ );
174
+ },
175
+ });
176
+
177
+ ReactNoop.render(<Foo />);
178
+ ReactNoop.flush();
179
+
180
+ let progressedUpdates = [];
181
+ function createUpdate(letter) {
182
+ return () => {
183
+ progressedUpdates.push(letter);
184
+ return {
185
+ [letter]: letter,
186
+ };
187
+ };
188
+ }
189
+
190
+ instance.setState(createUpdate('a'));
191
+ instance.setState(createUpdate('b'));
192
+ instance.setState(createUpdate('c'));
193
+
194
+ // Do just enough work to begin the update but not enough to flush it
195
+ ReactNoop.flushDeferredPri(20);
196
+ expect(ReactNoop.getChildren()).toEqual([span('')]);
197
+ expect(progressedUpdates).toEqual(['a', 'b', 'c']);
198
+
199
+ progressedUpdates = [];
200
+
201
+ instance.setState(createUpdate('f'));
202
+ ReactNoop.performAnimationWork(() => {
203
+ instance.setState(createUpdate('d'));
204
+ instance.replaceState(createUpdate('e'));
205
+ });
206
+ instance.setState(createUpdate('g'));
207
+
208
+ // Updates a, b, and c were aborted, so they should be applied first even
209
+ // though they have low priority. Update f was scheduled after the render
210
+ // was aborted, so it should come after d and e, which have higher priority.
211
+ // Because e is a replaceState, d gets dropped.
212
+ ReactNoop.flush();
213
+ expect(ReactNoop.getChildren()).toEqual([span('efg')]);
214
+ // Ensure that update d is not progressed before being replaced.
215
+ expect(progressedUpdates).toEqual(['e', 'f', 'g']);
216
+ });
217
+
218
+ it('does not call callbacks that are scheduled by another callback until a later commit', () => {
219
+ let ops = [];
220
+ class Foo extends React.Component {
221
+ state = {};
222
+ componentDidMount() {
223
+ ops.push('did mount');
224
+ this.setState({ a: 'a' }, () => {
225
+ ops.push('callback a');
226
+ this.setState({ b: 'b' }, () => {
227
+ ops.push('callback b');
228
+ });
229
+ });
230
+ }
231
+ render() {
232
+ ops.push('render');
233
+ return <div />;
234
+ }
235
+ }
236
+
237
+ ReactNoop.render(<Foo />);
238
+ ReactNoop.flush();
239
+ expect(ops).toEqual([
240
+ 'render',
241
+ 'did mount',
242
+ 'render',
243
+ 'callback a',
244
+ 'render',
245
+ 'callback b',
246
+ ]);
247
+ });
248
+});