3
let Fragment;
4
let ReactNoop;
5
let Scheduler;
6
-let ReactCache;
6
let Suspense;
7
+let textCache;
8
9
-let TextResource;
10
-let textResourceShouldFail;
9
+let readText;
10
+let resolveText;
11
+let rejectText;
12
13
describe('ReactSuspenseWithNoopRenderer', () => {
14
if (!__EXPERIMENTAL__) {
27
Fragment = React.Fragment;
28
ReactNoop = require('react-noop-renderer');
29
Scheduler = require('scheduler');
29
- ReactCache = require('react-cache');
30
Suspense = React.Suspense;
31
32
- TextResource = ReactCache.unstable_createResource(
33
- ([text, ms = 0]) => {
34
- return new Promise((resolve, reject) =>
35
- setTimeout(() => {
36
- if (textResourceShouldFail) {
37
- Scheduler.unstable_yieldValue(`Promise rejected [${text}]`);
38
- reject(new Error('Failed to load: ' + text));
39
- } else {
40
- Scheduler.unstable_yieldValue(`Promise resolved [${text}]`);
41
- resolve(text);
42
- }
43
- }, ms),
44
- );
45
- },
46
- ([text, ms]) => text,
47
- );
48
- textResourceShouldFail = false;
32
+ textCache = new Map();
33
+
34
+ readText = text => {
35
+ const record = textCache.get(text);
36
+ if (record !== undefined) {
37
+ switch (record.status) {
38
+ case 'pending':
39
+ throw record.promise;
40
+ case 'rejected':
41
+ throw Error('Failed to load: ' + text);
42
+ case 'resolved':
43
+ return text;
44
+ }
45
+ } else {
46
+ let ping;
47
+ const promise = new Promise(resolve => (ping = resolve));
48
+ const newRecord = {
49
+ status: 'pending',
50
+ ping: ping,
51
+ promise,
52
+ };
53
+ textCache.set(text, newRecord);
54
+ throw promise;
55
+ }
56
+ };
57
+
58
+ resolveText = text => {
59
+ const record = textCache.get(text);
60
+ if (record !== undefined) {
61
+ if (record.status === 'pending') {
62
+ Scheduler.unstable_yieldValue(`Promise resolved [${text}]`);
63
+ record.ping();
64
+ record.ping = null;
65
+ record.status = 'resolved';
66
+ clearTimeout(record.promise._timer);
67
+ record.promise = null;
68
+ }
69
+ } else {
70
+ const newRecord = {
71
+ ping: null,
72
+ status: 'resolved',
73
+ promise: null,
74
+ };
75
+ textCache.set(text, newRecord);
76
+ }
77
+ };
78
+
79
+ rejectText = text => {
80
+ const record = textCache.get(text);
81
+ if (record !== undefined) {
82
+ if (record.status === 'pending') {
83
+ Scheduler.unstable_yieldValue(`Promise rejected [${text}]`);
84
+ record.ping();
85
+ record.status = 'rejected';
86
+ clearTimeout(record.promise._timer);
87
+ record.promise = null;
88
+ }
89
+ } else {
90
+ const newRecord = {
91
+ ping: null,
92
+ status: 'rejected',
93
+ promise: null,
94
+ };
95
+ textCache.set(text, newRecord);
96
+ }
97
+ };
98
});
99
100
// function div(...children) {
132
function AsyncText(props) {
133
const text = props.text;
134
try {
86
- TextResource.read([props.text, props.ms]);
135
+ readText(text);
136
Scheduler.unstable_yieldValue(text);
137
return <span prop={text} />;
138
} catch (promise) {
139
if (typeof promise.then === 'function') {
140
Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
141
+ if (typeof props.ms === 'number' && promise._timer === undefined) {
142
+ promise._timer = setTimeout(() => {
143
+ resolveText(text);
144
+ }, props.ms);
145
+ }
146
} else {
147
Scheduler.unstable_yieldValue(`Error! [${text}]`);
148
}
333
expect(ReactNoop.getChildren()).toEqual([]);
334
335
// Wait for data to resolve
282
- await advanceTimers(100);
336
+ await resolveText('B');
337
// Renders successfully
338
expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
339
expect(Scheduler).toFlushAndYield(['A', 'B', 'C', 'D']);
383
expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']);
384
expect(ReactNoop.getChildren()).toEqual([]);
385
332
- textResourceShouldFail = true;
333
- ReactNoop.expire(1000);
334
- await advanceTimers(1000);
335
- textResourceShouldFail = false;
386
+ await rejectText('Result');
387
388
expect(Scheduler).toHaveYielded(['Promise rejected [Result]']);
389
433
expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']);
434
expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
435
385
- textResourceShouldFail = true;
386
- ReactNoop.expire(3000);
387
- await advanceTimers(3000);
388
- textResourceShouldFail = false;
436
+ await rejectText('Result');
437
438
expect(Scheduler).toHaveYielded(['Promise rejected [Result]']);
439
expect(Scheduler).toFlushAndYield([
464
// Initial mount
465
ReactNoop.render(<App highPri="A" lowPri="1" />);
466
expect(Scheduler).toFlushAndYield(['A', 'Suspend! [1]', 'Loading...']);
419
- await advanceTimers(0);
467
+ await resolveText('1');
468
expect(Scheduler).toHaveYielded(['Promise resolved [1]']);
469
expect(Scheduler).toFlushAndYield(['A', '1']);
470
expect(ReactNoop.getChildren()).toEqual([span('A'), span('1')]);
487
expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]);
488
489
// Unblock the low-pri text and finish
442
- await advanceTimers(0);
490
+ await resolveText('2');
491
expect(Scheduler).toHaveYielded(['Promise resolved [2]']);
492
expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]);
493
});
520
expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'B', 'Loading...']);
521
expect(ReactNoop.getChildren()).toEqual([]);
522
475
- await advanceTimers(0);
523
+ await resolveText('A');
524
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
525
expect(Scheduler).toFlushAndYield(['A', 'B']);
526
expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]);
759
expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]);
760
761
// Once the promise resolves, we render the suspended view
714
- await advanceTimers(0);
762
+ await resolveText('Async');
763
expect(Scheduler).toHaveYielded(['Promise resolved [Async]']);
764
expect(Scheduler).toFlushAndYield(['Async']);
765
expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]);
1260
const text = props.text;
1261
Scheduler.unstable_yieldValue('constructor');
1262
try {
1215
- TextResource.read([props.text, props.ms]);
1263
+ readText(text);
1264
this.state = {text};
1265
} catch (promise) {
1266
if (typeof promise.then === 'function') {
1293
]);
1294
expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1295
1248
- await advanceTimers(1000);
1296
+ await resolveText('Hi');
1297
1298
expect(Scheduler).toHaveYielded(['Promise resolved [Hi]']);
1299
expect(Scheduler).toFlushExpired([
1335
'Suspend! [Hi]',
1336
'Loading...',
1337
// Re-render due to lifecycle update
1338
+ 'Suspend! [Hi]',
1339
'Loading...',
1340
]);
1341
expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1544
}
1545
render() {
1546
const text = this.props.text;
1498
- const ms = this.props.ms;
1547
try {
1500
- TextResource.read([text, ms]);
1548
+ readText(text);
1549
Scheduler.unstable_yieldValue(text);
1550
return <span prop={text} />;
1551
} catch (promise) {
1629
};
1630
}, [props.text]);
1631
const text = props.text;
1584
- const ms = props.ms;
1632
try {
1586
- TextResource.read([text, ms]);
1633
+ readText(text);
1634
Scheduler.unstable_yieldValue(text);
1635
return <span prop={text} />;
1636
} catch (promise) {
1687
</>,
1688
);
1689
1643
- Scheduler.unstable_advanceTime(500);
1644
- await advanceTimers(500);
1690
+ await resolveText('B');
1691
1692
expect(Scheduler).toHaveYielded(['Promise resolved [B]']);
1693
1736
'Effect [Loading...]',
1737
]);
1738
1693
- Scheduler.unstable_advanceTime(500);
1694
- await advanceTimers(500);
1739
+ await resolveText('B2');
1740
1741
expect(Scheduler).toHaveYielded(['Promise resolved [B2]']);
1742
2113
await ReactNoop.act(async () => show(true));
2114
2115
expect(Scheduler).toHaveYielded(['Suspend! [A]']);
2071
- Scheduler.unstable_advanceTime(100);
2072
- await advanceTimers(100);
2116
+ await resolveText('A');
2117
2118
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
2119
});
2138
await ReactNoop.act(async () => _setShow(true));
2139
2140
expect(Scheduler).toHaveYielded(['Suspend! [A]']);
2097
- Scheduler.unstable_advanceTime(100);
2098
- await advanceTimers(100);
2141
+ await resolveText('A');
2142
2143
expect(Scheduler).toHaveYielded(['Promise resolved [A]']);
2144
});
2872
foo.setState({suspend: false});
2873
});
2874
2875
+ expect(Scheduler).toHaveYielded(['Foo']);
2876
expect(root).toMatchRenderedOutput(<span prop="Foo" />);
2877
});
2878
2988
</>,
2989
);
2990
});
2991
+
2992
+ it(
2993
+ 'multiple updates originating inside a Suspense boundary at different ' +
2994
+ 'priority levels are not dropped',
2995
+ async () => {
2996
+ const {useState} = React;
2997
+ const root = ReactNoop.createRoot();
2998
+
2999
+ function Parent() {
3000
+ return (
3001
+ <>
3002
+ <Suspense fallback={<Text text="Loading..." />}>
3003
+ <Child />
3004
+ </Suspense>
3005
+ </>
3006
+ );
3007
+ }
3008
+
3009
+ let setText;
3010
+ function Child() {
3011
+ const [text, _setText] = useState('A');
3012
+ setText = _setText;
3013
+ return <AsyncText text={text} />;
3014
+ }
3015
+
3016
+ await resolveText('A');
3017
+ await ReactNoop.act(async () => {
3018
+ root.render(<Parent />);
3019
+ });
3020
+ expect(Scheduler).toHaveYielded(['A']);
3021
+ expect(root).toMatchRenderedOutput(<span prop="A" />);
3022
+
3023
+ await ReactNoop.act(async () => {
3024
+ // Schedule two updates that originate inside the Suspense boundary.
3025
+ // The first one causes the boundary to suspend. The second one is at
3026
+ // lower priority and unsuspends the tree.
3027
+ ReactNoop.discreteUpdates(() => {
3028
+ setText('B');
3029
+ });
3030
+ // Update to a value that has already resolved
3031
+ await resolveText('C');
3032
+ setText('C');
3033
+ });
3034
+ expect(Scheduler).toHaveYielded([
3035
+ // First we attempt the high pri update. It suspends.
3036
+ 'Suspend! [B]',
3037
+ 'Loading...',
3038
+ // Then we attempt the low pri update, which finishes successfully.
3039
+ 'C',
3040
+ ]);
3041
+ expect(root).toMatchRenderedOutput(<span prop="C" />);
3042
+ },
3043
+ );
3044
+
3045
+ it(
3046
+ 'multiple updates originating inside a Suspense boundary at different ' +
3047
+ 'priority levels are not dropped, including Idle updates',
3048
+ async () => {
3049
+ const {useState} = React;
3050
+ const root = ReactNoop.createRoot();
3051
+
3052
+ function Parent() {
3053
+ return (
3054
+ <>
3055
+ <Suspense fallback={<Text text="Loading..." />}>
3056
+ <Child />
3057
+ </Suspense>
3058
+ </>
3059
+ );
3060
+ }
3061
+
3062
+ let setText;
3063
+ function Child() {
3064
+ const [text, _setText] = useState('A');
3065
+ setText = _setText;
3066
+ return <AsyncText text={text} />;
3067
+ }
3068
+
3069
+ await resolveText('A');
3070
+ await ReactNoop.act(async () => {
3071
+ root.render(<Parent />);
3072
+ });
3073
+ expect(Scheduler).toHaveYielded(['A']);
3074
+ expect(root).toMatchRenderedOutput(<span prop="A" />);
3075
+
3076
+ await ReactNoop.act(async () => {
3077
+ // Schedule two updates that originate inside the Suspense boundary.
3078
+ // The first one causes the boundary to suspend. The second one is at
3079
+ // lower priority and unsuspends it by hiding the async component.
3080
+ setText('B');
3081
+
3082
+ await resolveText('C');
3083
+ Scheduler.unstable_runWithPriority(
3084
+ Scheduler.unstable_IdlePriority,
3085
+ () => {
3086
+ setText('C');
3087
+ },
3088
+ );
3089
+ });
3090
+ expect(Scheduler).toHaveYielded([
3091
+ // First we attempt the high pri update. It suspends.
3092
+ 'Suspend! [B]',
3093
+ 'Loading...',
3094
+ ]);
3095
+
3096
+ // Commit the placeholder to unblock the Idle update.
3097
+ await advanceTimers(250);
3098
+ expect(root).toMatchRenderedOutput(
3099
+ <>
3100
+ <span hidden={true} prop="A" />
3101
+ <span prop="Loading..." />
3102
+ </>,
3103
+ );
3104
+
3105
+ // Now flush the remaining work. The Idle update successfully finishes.
3106
+ expect(Scheduler).toFlushAndYield(['C']);
3107
+ expect(root).toMatchRenderedOutput(<span prop="C" />);
3108
+ },
3109
+ );
3110
+
3111
+ it(
3112
+ 'fallback component can update itself even after a high pri update to ' +
3113
+ 'the primary tree suspends',
3114
+ async () => {
3115
+ const {useState} = React;
3116
+ const root = ReactNoop.createRoot();
3117
+
3118
+ let setAppText;
3119
+ function App() {
3120
+ const [text, _setText] = useState('A');
3121
+ setAppText = _setText;
3122
+ return (
3123
+ <>
3124
+ <Suspense fallback={<Fallback />}>
3125
+ <AsyncText text={text} />
3126
+ </Suspense>
3127
+ </>
3128
+ );
3129
+ }
3130
+
3131
+ let setFallbackText;
3132
+ function Fallback() {
3133
+ const [text, _setText] = useState('Loading...');
3134
+ setFallbackText = _setText;
3135
+ return <Text text={text} />;
3136
+ }
3137
+
3138
+ // Resolve the initial tree
3139
+ await resolveText('A');
3140
+ await ReactNoop.act(async () => {
3141
+ root.render(<App />);
3142
+ });
3143
+ expect(Scheduler).toHaveYielded(['A']);
3144
+ expect(root).toMatchRenderedOutput(<span prop="A" />);
3145
+
3146
+ // Schedule an update inside the Suspense boundary that suspends.
3147
+ await ReactNoop.act(async () => {
3148
+ setAppText('B');
3149
+ });
3150
+ expect(Scheduler).toHaveYielded(['Suspend! [B]', 'Loading...']);
3151
+ // Commit the placeholder
3152
+ await advanceTimers(250);
3153
+ expect(root).toMatchRenderedOutput(
3154
+ <>
3155
+ <span hidden={true} prop="A" />
3156
+ <span prop="Loading..." />
3157
+ </>,
3158
+ );
3159
+
3160
+ // Schedule a high pri update on the boundary, and a lower pri update
3161
+ // on the fallback. We're testing to make sure the fallback can still
3162
+ // update even though the primary tree is suspended.
3163
+ await ReactNoop.act(async () => {
3164
+ ReactNoop.discreteUpdates(() => {
3165
+ setAppText('C');
3166
+ });
3167
+ setFallbackText('Still loading...');
3168
+ });
3169
+
3170
+ expect(Scheduler).toHaveYielded([
3171
+ // First try to update the suspended tree. It's still suspended.
3172
+ 'Suspend! [C]',
3173
+ 'Loading...',
3174
+ // Then complete the update to the fallback.
3175
+ 'Still loading...',
3176
+ ]);
3177
+ expect(root).toMatchRenderedOutput(
3178
+ <>
3179
+ <span hidden={true} prop="A" />
3180
+ <span prop="Still loading..." />
3181
+ </>,
3182
+ );
3183
+ },
3184
+ );
3185
+
3186
+ it(
3187
+ 'regression: primary fragment fiber is not always part of setState ' +
3188
+ 'return path',
3189
+ async () => {
3190
+ // Reproduces a bug where updates inside a suspended tree are dropped
3191
+ // because the fragment fiber we insert to wrap the hidden children is not
3192
+ // part of the return path, so it doesn't get marked during setState.
3193
+ const {useState} = React;
3194
+ const root = ReactNoop.createRoot();
3195
+
3196
+ function Parent() {
3197
+ return (
3198
+ <>
3199
+ <Suspense fallback={<Text text="Loading..." />}>
3200
+ <Child />
3201
+ </Suspense>
3202
+ </>
3203
+ );
3204
+ }
3205
+
3206
+ let setText;
3207
+ function Child() {
3208
+ const [text, _setText] = useState('A');
3209
+ setText = _setText;
3210
+ return <AsyncText text={text} />;
3211
+ }
3212
+
3213
+ // Mount an initial tree. Resolve A so that it doesn't suspend.
3214
+ await resolveText('A');
3215
+ await ReactNoop.act(async () => {
3216
+ root.render(<Parent />);
3217
+ });
3218
+ expect(Scheduler).toHaveYielded(['A']);
3219
+ // At this point, the setState return path follows current fiber.
3220
+ expect(root).toMatchRenderedOutput(<span prop="A" />);
3221
+
3222
+ // Schedule another update. This will "flip" the alternate pairs.
3223
+ await resolveText('B');
3224
+ await ReactNoop.act(async () => {
3225
+ setText('B');
3226
+ });
3227
+ expect(Scheduler).toHaveYielded(['B']);
3228
+ // Now the setState return path follows the *alternate* fiber.
3229
+ expect(root).toMatchRenderedOutput(<span prop="B" />);
3230
+
3231
+ // Schedule another update. This time, we'll suspend.
3232
+ await ReactNoop.act(async () => {
3233
+ setText('C');
3234
+ });
3235
+ expect(Scheduler).toHaveYielded(['Suspend! [C]', 'Loading...']);
3236
+
3237
+ // Commit. This will insert a fragment fiber to wrap around the component
3238
+ // that triggered the update.
3239
+ await ReactNoop.act(async () => {
3240
+ await advanceTimers(250);
3241
+ });
3242
+ // The fragment fiber is part of the current tree, but the setState return
3243
+ // path still follows the alternate path. That means the fragment fiber is
3244
+ // not part of the return path.
3245
+ expect(root).toMatchRenderedOutput(
3246
+ <>
3247
+ <span hidden={true} prop="B" />
3248
+ <span prop="Loading..." />
3249
+ </>,
3250
+ );
3251
+
3252
+ // Update again. This should unsuspend the tree.
3253
+ await resolveText('D');
3254
+ await ReactNoop.act(async () => {
3255
+ setText('D');
3256
+ });
3257
+ // Even though the fragment fiber is not part of the return path, we should
3258
+ // be able to finish rendering.
3259
+ expect(Scheduler).toHaveYielded(['D']);
3260
+ expect(root).toMatchRenderedOutput(<span prop="D" />);
3261
+ },
3262
+ );
3263
});