1
+let React;
2
+let ReactNoop;
3
+let Scheduler;
4
+let act;
5
+let Offscreen;
6
+let Suspense;
7
+let useState;
8
+let useEffect;
9
+let textCache;
10
+
11
+describe('ReactOffscreen', () => {
12
+ beforeEach(() => {
13
+ jest.resetModules();
14
+
15
+ React = require('react');
16
+ ReactNoop = require('react-noop-renderer');
17
+ Scheduler = require('scheduler');
18
+ act = require('jest-react').act;
19
+ Offscreen = React.unstable_Offscreen;
20
+ Suspense = React.Suspense;
21
+ useState = React.useState;
22
+ useEffect = React.useEffect;
23
+
24
+ textCache = new Map();
25
+ });
26
+
27
+ function resolveText(text) {
28
+ const record = textCache.get(text);
29
+ if (record === undefined) {
30
+ const newRecord = {
31
+ status: 'resolved',
32
+ value: text,
33
+ };
34
+ textCache.set(text, newRecord);
35
+ } else if (record.status === 'pending') {
36
+ const thenable = record.value;
37
+ record.status = 'resolved';
38
+ record.value = text;
39
+ thenable.pings.forEach(t => t());
40
+ }
41
+ }
42
+
43
+ function readText(text) {
44
+ const record = textCache.get(text);
45
+ if (record !== undefined) {
46
+ switch (record.status) {
47
+ case 'pending':
48
+ Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
49
+ throw record.value;
50
+ case 'rejected':
51
+ throw record.value;
52
+ case 'resolved':
53
+ return record.value;
54
+ }
55
+ } else {
56
+ Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
57
+ const thenable = {
58
+ pings: [],
59
+ then(resolve) {
60
+ if (newRecord.status === 'pending') {
61
+ thenable.pings.push(resolve);
62
+ } else {
63
+ Promise.resolve().then(() => resolve(newRecord.value));
64
+ }
65
+ },
66
+ };
67
+
68
+ const newRecord = {
69
+ status: 'pending',
70
+ value: thenable,
71
+ };
72
+ textCache.set(text, newRecord);
73
+
74
+ throw thenable;
75
+ }
76
+ }
77
+
78
+ function Text({text}) {
79
+ Scheduler.unstable_yieldValue(text);
80
+ return text;
81
+ }
82
+
83
+ function AsyncText({text}) {
84
+ readText(text);
85
+ Scheduler.unstable_yieldValue(text);
86
+ return text;
87
+ }
88
+
89
+ // Only works in new reconciler
90
+ // @gate variant
91
+ test('detect updates to a hidden tree during a concurrent event', async () => {
92
+ // This is a pretty complex test case. It relates to how we detect if an
93
+ // update is made to a hidden tree: when scheduling the update, we walk up
94
+ // the fiber return path to see if any of the parents is a hidden Offscreen
95
+ // component. This doesn't work if there's already a render in progress,
96
+ // because the tree might be about to flip to hidden. To avoid a data race,
97
+ // queue updates atomically: wait to queue the update until after the
98
+ // current render has finished.
99
+
100
+ let setInner;
101
+ function Child({outer}) {
102
+ const [inner, _setInner] = useState(0);
103
+ setInner = _setInner;
104
+
105
+ useEffect(() => {
106
+ // Inner and outer values are always updated simultaneously, so they
107
+ // should always be consistent.
108
+ if (inner !== outer) {
109
+ Scheduler.unstable_yieldValue(
110
+ 'Tearing! Inner and outer are inconsistent!',
111
+ );
112
+ } else {
113
+ Scheduler.unstable_yieldValue('Inner and outer are consistent');
114
+ }
115
+ }, [inner, outer]);
116
+
117
+ return <Text text={'Inner: ' + inner} />;
118
+ }
119
+
120
+ let setOuter;
121
+ function App({show}) {
122
+ const [outer, _setOuter] = useState(0);
123
+ setOuter = _setOuter;
124
+ return (
125
+ <>
126
+ <span>
127
+ <Text text={'Outer: ' + outer} />
128
+ </span>
129
+ <Offscreen mode={show ? 'visible' : 'hidden'}>
130
+ <span>
131
+ <Child outer={outer} />
132
+ </span>
133
+ </Offscreen>
134
+ <Suspense fallback={<Text text="Loading..." />}>
135
+ <span>
136
+ <AsyncText text={'Async: ' + outer} />
137
+ </span>
138
+ </Suspense>
139
+ </>
140
+ );
141
+ }
142
+
143
+ // Render a hidden tree
144
+ const root = ReactNoop.createRoot();
145
+ resolveText('Async: 0');
146
+ await act(async () => {
147
+ root.render(<App show={true} />);
148
+ });
149
+ expect(Scheduler).toHaveYielded([
150
+ 'Outer: 0',
151
+ 'Inner: 0',
152
+ 'Async: 0',
153
+ 'Inner and outer are consistent',
154
+ ]);
155
+ expect(root).toMatchRenderedOutput(
156
+ <>
157
+ <span>Outer: 0</span>
158
+ <span>Inner: 0</span>
159
+ <span>Async: 0</span>
160
+ </>,
161
+ );
162
+
163
+ await act(async () => {
164
+ // Update a value both inside and outside the hidden tree. These values
165
+ // must always be consistent.
166
+ setOuter(1);
167
+ setInner(1);
168
+ // In the same render, also hide the offscreen tree.
169
+ root.render(<App show={false} />);
170
+
171
+ expect(Scheduler).toFlushAndYieldThrough([
172
+ // The outer update will commit, but the inner update is deferred until
173
+ // a later render.
174
+ 'Outer: 1',
175
+
176
+ // Something suspended. This means we won't commit immediately; there
177
+ // will be an async gap between render and commit. In this test, we will
178
+ // use this property to schedule a concurrent update. The fact that
179
+ // we're using Suspense to schedule a concurrent update is not directly
180
+ // relevant to the test — we could also use time slicing, but I've
181
+ // chosen to use Suspense the because implementation details of time
182
+ // slicing are more volatile.
183
+ 'Suspend! [Async: 1]',
184
+
185
+ 'Loading...',
186
+ ]);
187
+ // Assert that we haven't committed quite yet
188
+ expect(root).toMatchRenderedOutput(
189
+ <>
190
+ <span>Outer: 0</span>
191
+ <span>Inner: 0</span>
192
+ <span>Async: 0</span>
193
+ </>,
194
+ );
195
+
196
+ // Before the tree commits, schedule a concurrent event. The inner update
197
+ // is to a tree that's just about to be hidden.
198
+ setOuter(2);
199
+ setInner(2);
200
+
201
+ // Commit the previous render.
202
+ jest.runAllTimers();
203
+ expect(root).toMatchRenderedOutput(
204
+ <>
205
+ <span>Outer: 1</span>
206
+ <span hidden={true}>Inner: 0</span>
207
+ <span hidden={true}>Async: 0</span>
208
+ Loading...
209
+ </>,
210
+ );
211
+
212
+ // Now reveal the hidden tree at high priority.
213
+ ReactNoop.flushSync(() => {
214
+ root.render(<App show={true} />);
215
+ });
216
+ expect(Scheduler).toHaveYielded([
217
+ 'Outer: 1',
218
+
219
+ // There are two pending updates on Inner, but only the first one
220
+ // is processed, even though they share the same lane. If the second
221
+ // update were erroneously processed, then Inner would be inconsistent
222
+ // with Outer.
223
+ 'Inner: 1',
224
+
225
+ 'Suspend! [Async: 1]',
226
+ 'Loading...',
227
+ 'Inner and outer are consistent',
228
+ ]);
229
+ });
230
+ expect(Scheduler).toHaveYielded([
231
+ 'Outer: 2',
232
+ 'Inner: 2',
233
+ 'Suspend! [Async: 2]',
234
+ 'Loading...',
235
+ 'Inner and outer are consistent',
236
+ ]);
237
+ expect(root).toMatchRenderedOutput(
238
+ <>
239
+ <span>Outer: 2</span>
240
+ <span>Inner: 2</span>
241
+ <span hidden={true}>Async: 0</span>
242
+ Loading...
243
+ </>,
244
+ );
245
+ });
246
+});