1
-/**
2
- * Copyright (c) Meta Platforms, Inc. and affiliates.
3
- *
4
- * This source code is licensed under the MIT license found in the
5
- * LICENSE file in the root directory of this source tree.
6
- *
7
- * @emails react-core
8
- * @jest-environment node
9
- */
10
-
11
-'use strict';
12
-
13
-let React;
14
-let ReactNoop;
15
-let Scheduler;
16
-
17
-describe('ReactIncrementalTriangle', () => {
18
- beforeEach(() => {
19
- jest.resetModules();
20
-
21
- React = require('react');
22
- ReactNoop = require('react-noop-renderer');
23
- Scheduler = require('scheduler');
24
- });
25
-
26
- function span(prop) {
27
- return {type: 'span', children: [], prop, hidden: false};
28
- }
29
-
30
- const FLUSH = 'FLUSH';
31
- function flush(unitsOfWork = Infinity) {
32
- return {
33
- type: FLUSH,
34
- unitsOfWork,
35
- };
36
- }
37
-
38
- const FLUSH_ALL = 'FLUSH_ALL';
39
- function flushAll() {
40
- return {
41
- type: FLUSH_ALL,
42
- };
43
- }
44
-
45
- const STEP = 'STEP';
46
- function step(counter) {
47
- return {
48
- type: STEP,
49
- counter,
50
- };
51
- }
52
-
53
- const INTERRUPT = 'INTERRUPT';
54
- function interrupt(key) {
55
- return {
56
- type: INTERRUPT,
57
- };
58
- }
59
-
60
- const TOGGLE = 'TOGGLE';
61
- function toggle(childIndex) {
62
- return {
63
- type: TOGGLE,
64
- childIndex,
65
- };
66
- }
67
-
68
- const EXPIRE = 'EXPIRE';
69
- function expire(ms) {
70
- return {
71
- type: EXPIRE,
72
- ms,
73
- };
74
- }
75
-
76
- const STOP = 'STOP';
77
-
78
- function randomInteger(min, max) {
79
- min = Math.ceil(min);
80
- max = Math.floor(max);
81
- return Math.floor(Math.random() * (max - min)) + min;
82
- }
83
-
84
- function formatAction(action) {
85
- switch (action.type) {
86
- case FLUSH:
87
- return `flush(${action.unitsOfWork})`;
88
- case FLUSH_ALL:
89
- return 'flushAll()';
90
- case STEP:
91
- return `step(${action.counter})`;
92
- case INTERRUPT:
93
- return 'interrupt()';
94
- case TOGGLE:
95
- return `toggle(${action.childIndex})`;
96
- case EXPIRE:
97
- return `expire(${action.ms})`;
98
- default:
99
- throw new Error('Switch statement should be exhaustive');
100
- }
101
- }
102
-
103
- function formatActions(actions) {
104
- let result = 'simulate(';
105
- for (let i = 0; i < actions.length; i++) {
106
- const action = actions[i];
107
- result += formatAction(action);
108
- if (i !== actions.length - 1) {
109
- result += ', ';
110
- }
111
- }
112
- result += ')';
113
- return result;
114
- }
115
-
116
- const MAX_DEPTH = 3;
117
- const TOTAL_CHILDREN = Math.pow(3, MAX_DEPTH);
118
- let TOTAL_TRIANGLES = 0;
119
- for (let i = 0; i <= MAX_DEPTH; i++) {
120
- TOTAL_TRIANGLES += Math.pow(3, i);
121
- }
122
-
123
- function randomAction() {
124
- const weights = [
125
- [FLUSH, 1],
126
- [FLUSH_ALL, 1],
127
- [STEP, 1],
128
- [INTERRUPT, 1],
129
- [TOGGLE, 1],
130
- [EXPIRE, 1],
131
- ];
132
- let totalWeight = 0;
133
- for (let i = 0; i < weights.length; i++) {
134
- totalWeight += weights[i][1];
135
- }
136
-
137
- const randomNumber = Math.random() * totalWeight;
138
- let actionType;
139
- let remainingWeight = randomNumber;
140
- for (let i = 0; i < weights.length; i++) {
141
- const [option, weight] = weights[i];
142
- remainingWeight -= weight;
143
- if (remainingWeight <= 0) {
144
- actionType = option;
145
- break;
146
- }
147
- }
148
-
149
- switch (actionType) {
150
- case FLUSH:
151
- return flush(randomInteger(0, TOTAL_TRIANGLES * 1.5));
152
- case FLUSH_ALL:
153
- return flushAll();
154
- case STEP:
155
- return step(randomInteger(0, 10));
156
- case INTERRUPT:
157
- return interrupt();
158
- case TOGGLE:
159
- return toggle(randomInteger(0, TOTAL_TRIANGLES));
160
- case EXPIRE:
161
- return expire(randomInteger(0, 1500));
162
- default:
163
- throw new Error('Switch statement should be exhaustive');
164
- }
165
- }
166
-
167
- function randomActions(n) {
168
- const actions = [];
169
- for (let i = 0; i < n; i++) {
170
- actions.push(randomAction());
171
- }
172
- return actions;
173
- }
174
-
175
- function TriangleSimulator(rootID) {
176
- const CounterContext = React.createContext([]);
177
- const ActiveContext = React.createContext(0);
178
-
179
- let triangles = [];
180
- let leafTriangles = [];
181
- const yieldAfterEachRender = false;
182
- class Triangle extends React.Component {
183
- constructor(props) {
184
- super();
185
- this.state = {isActive: false};
186
- this.child = React.createRef(null);
187
- }
188
- activate() {
189
- this.setState({isActive: true});
190
- }
191
- deactivate() {
192
- this.setState({isActive: false});
193
- }
194
- shouldComponentUpdate(nextProps, nextState) {
195
- return (
196
- this.props.counter !== nextProps.counter ||
197
- this.props.activeDepth !== nextProps.activeDepth ||
198
- this.state.isActive !== nextState.isActive
199
- );
200
- }
201
- componentDidMount() {
202
- this.index = triangles.length;
203
- triangles.push(this);
204
- if (this.props.remainingDepth === 0) {
205
- this.leafIndex = leafTriangles.length;
206
- leafTriangles.push(this);
207
- }
208
- }
209
- componentDidUpdate() {
210
- if (this.child.current !== null) {
211
- const {prop: currentCounter} = JSON.parse(this.child.current.prop);
212
- if (this.props.counter !== currentCounter) {
213
- throw new Error('Incorrect props in lifecycle');
214
- }
215
- }
216
- }
217
- render() {
218
- if (yieldAfterEachRender) {
219
- Scheduler.log(this);
220
- }
221
- const {counter, remainingDepth} = this.props;
222
- return (
223
- <ActiveContext.Consumer>
224
- {activeContext => (
225
- <CounterContext.Consumer>
226
- {counterContext => {
227
- const activeDepthProp = this.state.isActive
228
- ? this.props.activeDepth + 1
229
- : this.props.activeDepth;
230
- const activeDepthContext = this.state.isActive
231
- ? activeContext + 1
232
- : activeContext;
233
- if (remainingDepth === 0) {
234
- // Leaf
235
- const output = JSON.stringify({
236
- prop: counter,
237
- isActive: this.state.isActive,
238
- counterContext: counterContext,
239
- activeDepthProp,
240
- activeDepthContext,
241
- });
242
- return <span ref={this.child} prop={output} />;
243
- }
244
-
245
- return (
246
- <ActiveContext.Provider value={activeDepthContext}>
247
- <CounterContext.Provider value={counter}>
248
- <Triangle
249
- counter={counter}
250
- activeDepth={activeDepthProp}
251
- remainingDepth={remainingDepth - 1}
252
- />
253
- <Triangle
254
- counter={counter}
255
- activeDepth={activeDepthProp}
256
- remainingDepth={remainingDepth - 1}
257
- />
258
- <Triangle
259
- counter={counter}
260
- activeDepth={activeDepthProp}
261
- remainingDepth={remainingDepth - 1}
262
- />
263
- </CounterContext.Provider>
264
- </ActiveContext.Provider>
265
- );
266
- }}
267
- </CounterContext.Consumer>
268
- )}
269
- </ActiveContext.Consumer>
270
- );
271
- }
272
- }
273
-
274
- let appInstance;
275
- class App extends React.Component {
276
- state = {counter: 0};
277
- interrupt() {
278
- // Triggers a restart from the top.
279
- this.forceUpdate();
280
- }
281
- setCounter(counter) {
282
- const currentCounter = this.state.counter;
283
- this.setState({counter});
284
- return currentCounter;
285
- }
286
- render() {
287
- appInstance = this;
288
- return (
289
- <Triangle
290
- counter={this.state.counter}
291
- activeDepth={0}
292
- remainingDepth={this.props.remainingDepth}
293
- />
294
- );
295
- }
296
- }
297
-
298
- let keyCounter = 0;
299
- function reset(nextStep = 0) {
300
- triangles = [];
301
- leafTriangles = [];
302
- // Remounts the whole tree by changing the key
303
- if (rootID) {
304
- ReactNoop.renderToRootWithID(
305
- <App remainingDepth={MAX_DEPTH} key={keyCounter++} />,
306
- rootID,
307
- );
308
- } else {
309
- ReactNoop.render(<App remainingDepth={MAX_DEPTH} key={keyCounter++} />);
310
- }
311
- Scheduler.unstable_flushAllWithoutAsserting();
312
- assertConsistentTree();
313
- return appInstance;
314
- }
315
-
316
- reset();
317
-
318
- function assertConsistentTree(activeTriangleIndices = new Set(), counter) {
319
- const childrenJSX = ReactNoop.getPendingChildrenAsJSX(rootID);
320
- const children = childrenJSX === null ? [] : childrenJSX.props.children;
321
-
322
- if (children.length !== TOTAL_CHILDREN) {
323
- throw new Error('Wrong number of children.');
324
- }
325
-
326
- let expectedCounter = counter;
327
-
328
- for (let i = 0; i < children.length; i++) {
329
- const child = children[i];
330
-
331
- const output = JSON.parse(child.props.prop);
332
- const prop = output.prop;
333
- const isActive = output.isActive;
334
- const counterContext = output.counterContext;
335
- const activeDepthProp = output.activeDepthProp;
336
- const activeDepthContext = output.activeDepthContext;
337
-
338
- // If an expected counter is not specified, use the value of the
339
- // first child.
340
- if (expectedCounter === undefined) {
341
- expectedCounter = prop;
342
- }
343
- const expectedIsActive = activeTriangleIndices.has(i);
344
-
345
- if (prop !== expectedCounter) {
346
- throw new Error(
347
- `Triangle ${i} is inconsistent: prop ${prop} instead of ` +
348
- expectedCounter,
349
- );
350
- }
351
-
352
- if (isActive !== expectedIsActive) {
353
- throw new Error(
354
- `Triangle ${i} is inconsistent: isActive ${isActive} instead of ` +
355
- expectedIsActive,
356
- );
357
- }
358
-
359
- if (counterContext !== prop) {
360
- throw new Error(
361
- `Triangle ${i} is inconsistent: prop ${prop} does not match ` +
362
- `counterContext ${counterContext}`,
363
- );
364
- }
365
-
366
- if (activeDepthContext !== activeDepthProp) {
367
- throw new Error(
368
- `Triangle ${i} is inconsistent: activeDepthProp ` +
369
- `${activeDepthProp} does not match activeDepthContext ` +
370
- activeDepthContext,
371
- );
372
- }
373
- }
374
- }
375
-
376
- function* simulateAndYield() {
377
- const app = reset();
378
- let expectedCounterAtEnd = app.state.counter;
379
-
380
- const activeIndices = new Set();
381
- const activeLeafIndices = new Set();
382
- let action;
383
- while (true) {
384
- action = yield;
385
- if (action === STOP) {
386
- break;
387
- }
388
- ReactNoop.flushSync(() => {
389
- switch (action.type) {
390
- case FLUSH:
391
- Scheduler.unstable_flushNumberOfYields(action.unitsOfWork);
392
- break;
393
- case FLUSH_ALL:
394
- Scheduler.unstable_flushAllWithoutAsserting();
395
- break;
396
- case STEP:
397
- ReactNoop.deferredUpdates(() => {
398
- app.setCounter(action.counter);
399
- expectedCounterAtEnd = action.counter;
400
- });
401
- break;
402
- case INTERRUPT:
403
- app.interrupt();
404
- break;
405
- case TOGGLE:
406
- const targetTriangle = triangles[action.childIndex];
407
- if (targetTriangle === undefined) {
408
- throw new Error('Target index is out of bounds');
409
- }
410
- const index = targetTriangle.index;
411
- const leafIndex = targetTriangle.leafIndex;
412
- if (activeIndices.has(index)) {
413
- activeIndices.delete(index);
414
- if (leafIndex !== undefined) {
415
- activeLeafIndices.delete(leafIndex);
416
- }
417
- targetTriangle.deactivate();
418
- } else {
419
- activeIndices.add(index);
420
- if (leafIndex !== undefined) {
421
- activeLeafIndices.add(leafIndex);
422
- }
423
- targetTriangle.activate();
424
- }
425
- break;
426
- case EXPIRE:
427
- ReactNoop.expire(action.ms);
428
- break;
429
- default:
430
- throw new Error('Switch statement should be exhaustive');
431
- }
432
- });
433
- assertConsistentTree(activeLeafIndices);
434
- }
435
- // Flush remaining work
436
- Scheduler.unstable_flushAllWithoutAsserting();
437
- ReactNoop.flushSync();
438
- assertConsistentTree(activeLeafIndices, expectedCounterAtEnd);
439
- }
440
-
441
- function simulate(...actions) {
442
- const gen = simulateAndYield();
443
- // Call this once to prepare the generator
444
- gen.next();
445
- // eslint-disable-next-line no-for-of-loops/no-for-of-loops
446
- for (const action of actions) {
447
- gen.next(action);
448
- }
449
- gen.next(STOP);
450
- }
451
-
452
- return {
453
- simulateAndYield,
454
- simulate,
455
- randomAction,
456
- randomActions,
457
- };
458
- }
459
-
460
- describe('single root', () => {
461
- // These tests are not deterministic because the inputs are randomized. It
462
- // runs a limited number of tests on every run. If it fails, it will output
463
- // the case that led to the failure. Add the failing case to the test above
464
- // to prevent future regressions.
465
- it('hard-coded tests', () => {
466
- const {simulate} = TriangleSimulator();
467
- simulate(step(1));
468
- simulate(toggle(0), step(1), toggle(0));
469
- simulate(step(1), toggle(0), flush(2), step(2), toggle(0));
470
- simulate(step(1), flush(3), toggle(0), step(0));
471
- simulate(step(1), flush(3), toggle(18), step(0));
472
- simulate(step(4), flush(52), expire(1476), flush(17), step(0));
473
- simulate(interrupt(), toggle(10), step(2), expire(990), flush(46));
474
- simulate(interrupt(), step(6), step(7), toggle(6), interrupt());
475
- simulate(interrupt(), toggle(31), toggle(31), toggle(13), step(1));
476
- });
477
-
478
- it('generative tests', () => {
479
- const {simulate} = TriangleSimulator();
480
-
481
- const limit = 1000;
482
-
483
- for (let i = 0; i < limit; i++) {
484
- const actions = randomActions(5);
485
- try {
486
- simulate(...actions);
487
- } catch (e) {
488
- console.error(
489
- `Triangle fuzz tester error! Copy and paste the following line into the test suite:
490
-${formatActions(actions)}
491
- `,
492
- );
493
- throw e;
494
- }
495
- }
496
- });
497
- });
498
-
499
- describe('multiple roots', () => {
500
- const rootIDs = ['a', 'b', 'c'];
501
-
502
- function randomActionsPerRoot() {
503
- function randomRootID() {
504
- const index = randomInteger(0, rootIDs.length);
505
- return rootIDs[index];
506
- }
507
-
508
- const actions = [];
509
- for (let i = 0; i < 10; i++) {
510
- const rootID = randomRootID();
511
- const action = randomAction();
512
- actions.push([rootID, action]);
513
- }
514
- return actions;
515
- }
516
-
517
- function formatActionsPerRoot(actions) {
518
- let result = 'simulateMultipleRoots(';
519
- for (let i = 0; i < actions.length; i++) {
520
- const [rootID, action] = actions[i];
521
- result += `['${rootID}', ${formatAction(action)}]`;
522
- if (i !== actions.length - 1) {
523
- result += ', ';
524
- }
525
- }
526
- result += ')';
527
- return result;
528
- }
529
-
530
- function simulateMultipleRoots(...actions) {
531
- const roots = new Map();
532
- // eslint-disable-next-line no-for-of-loops/no-for-of-loops
533
- for (const rootID of rootIDs) {
534
- const simulator = TriangleSimulator(rootID);
535
- const generator = simulator.simulateAndYield();
536
- // Call this once to prepare the generator
537
- generator.next();
538
- roots.set(rootID, generator);
539
- }
540
-
541
- actions.forEach(([rootID, action]) => {
542
- const generator = roots.get(rootID);
543
- generator.next(action);
544
- });
545
- roots.forEach(generator => {
546
- generator.next(STOP);
547
- });
548
- }
549
-
550
- it('hard-coded tests', () => {
551
- simulateMultipleRoots(
552
- ['b', interrupt()],
553
- ['a', toggle(22)],
554
- ['c', step(4)],
555
- ['a', expire(10)],
556
- ['a', interrupt()],
557
- ['c', step(2)],
558
- ['b', interrupt()],
559
- );
560
-
561
- simulateMultipleRoots(
562
- ['c', toggle(0)],
563
- ['c', step(1)],
564
- ['b', flush(7)],
565
- ['c', toggle(0)],
566
- );
567
-
568
- simulateMultipleRoots(
569
- ['c', step(1)],
570
- ['c', expire(5000)],
571
- ['b', toggle(1)],
572
- );
573
- });
574
-
575
- it('generative tests', () => {
576
- const limit = 100;
577
- for (let i = 0; i < limit; i++) {
578
- const actions = randomActionsPerRoot();
579
- try {
580
- simulateMultipleRoots(...actions);
581
- } catch (e) {
582
- console.error(
583
- `Triangle fuzz tester error! Copy and paste the following line into the test suite:
584
-${formatActionsPerRoot(actions)}
585
- `,
586
- );
587
- throw e;
588
- }
589
- }
590
- });
591
- });
592
-});