@samitouri / QOS-React / commits / dac9202a9c

Hide timed-out children instead of deleting them so their state is preserved (#13823)

* Store the start time on `updateQueue` instead of `stateNode` Originally I did this to free the `stateNode` field to store a second set of children. I don't we'll need this anymore, since we use fragment fibers instead. But I still think using `updateQueue` makes more sense so I'll leave this in. * Use fragment fibers to keep the primary and fallback children separate If the children timeout, we switch to showing the fallback children in place of the "primary" children. However, we don't want to delete the primary children because then their state will be lost (both the React state and the host state, e.g. uncontrolled form inputs). Instead we keep them mounted and hide them. Both the fallback children AND the primary children are rendered at the same time. Once the primary children are un-suspended, we can delete the fallback children — don't need to preserve their state. The two sets of children are siblings in the host environment, but semantically, for purposes of reconciliation, they are two separate sets. So we store them using two fragment fibers. However, we want to avoid allocating extra fibers for every placeholder. They're only necessary when the children time out, because that's the only time when both sets are mounted. So, the extra fragment fibers are only used if the children time out. Otherwise, we render the primary children directly. This requires some custom reconciliation logic to preserve the state of the primary children. It's essentially a very basic form of re-parenting. * Use `memoizedState` to store various pieces of SuspenseComponent's state SuspenseComponent has three pieces of state: - alreadyCaptured: Whether a component in the child subtree already suspended. If true, subsequent suspends should bubble up to the next boundary. - didTimeout: Whether the boundary renders the primary or fallback children. This is separate from `alreadyCaptured` because outside of strict mode, when a boundary times out, the first commit renders the primary children in an incomplete state, then performs a second commit to switch the fallback. In that first commit, `alreadyCaptured` is false and `didTimeout` is true. - timedOutAt: The time at which the boundary timed out. This is separate from `didTimeout` because it's not set unless the boundary actually commits. These were previously spread across several fields. This happens to make the non-strict case a bit less hacky; the logic for that special case is now mostly localized to the UnwindWork module. * Hide timed-out Suspense children When a subtree takes too long to load, we swap its contents out for a fallback to unblock the rest of the tree. Because we don't want to lose the state of the timed out view, we shouldn't actually delete the nodes from the tree. Instead, we'll keep them mounted and hide them visually. When the subtree is unblocked, we un-hide it, having preserved the existing state. Adds additional host config methods. For mutation mode: - hideInstance - hideTextInstance - unhideInstance - unhideTextInstance For persistent mode: - cloneHiddenInstance - cloneUnhiddenInstance - createHiddenTextInstance I've only implemented the new methods in the noop and test renderers. I'll implement them in the other renderers in subsequent commits. * Include `hidden` prop in noop renderer's output This will be used in subsequent commits to test that timed-out children are properly hidden. Also adds getChildrenAsJSX() method as an alternative to using getChildren(). (Ideally all our tests would use test renderer #oneday.) * Implement hide/unhide host config methods for DOM renderer For DOM nodes, we hide using `el.style.display = 'none'`. Text nodes don't have style, so we hide using `text.textContent = ''`. * Implement hide/unhide host config methods for Art renderer * Create DOM fixture that tests state preservation of timed out content * Account for class components that suspend outside concurrent mode Need to distinguish mount from update. An unfortunate edge case :( * Fork appendAllChildren between persistent and mutation mode * Remove redundant check for existence of el.style * Schedule placement effect on indeterminate components In non-concurrent mode, indeterminate fibers may commit in an inconsistent state. But when they update, we should throw out the old fiber and start fresh. Which means the new fiber needs a placement effect. * Pass null instead of current everywhere in mountIndeterminateComponent

Andrew Clark committed Oct 18, 2018 at 15:37 UTC dac9202a9c5add480f853bcad2ee04d371e72c0c
36 files changed +1991 -367
fixtures/dom/src/components/Header.js
+1
@@ -68,6 +68,7 @@ class Header extends React.Component {
68 <option value="/pointer-events">Pointer Events</option>
69 <option value="/mouse-events">Mouse Events</option>
70 <option value="/selection-events">Selection Events</option>
71 + <option value="/suspense">Suspense</option>
72 </select>
73 </label>
74 <label htmlFor="react_version">
fixtures/dom/src/components/fixtures/suspense/index.js new
+321
@@ -0,0 +1,321 @@
1 +import Fixture from '../../Fixture';
2 +import FixtureSet from '../../FixtureSet';
3 +import TestCase from '../../TestCase';
4 +
5 +const React = window.React;
6 +const ReactDOM = window.ReactDOM;
7 +
8 +const Suspense = React.unstable_Suspense;
9 +
10 +let cache = new Set();
11 +
12 +function AsyncStep({text, ms}) {
13 + if (!cache.has(text)) {
14 + throw new Promise(resolve =>
15 + setTimeout(() => {
16 + cache.add(text);
17 + resolve();
18 + }, ms)
19 + );
20 + }
21 + return null;
22 +}
23 +
24 +let suspendyTreeIdCounter = 0;
25 +class SuspendyTreeChild extends React.Component {
26 + id = suspendyTreeIdCounter++;
27 + state = {
28 + step: 1,
29 + isHidden: false,
30 + };
31 + increment = () => this.setState(s => ({step: s.step + 1}));
32 +
33 + componentDidMount() {
34 + document.addEventListener('keydown', this.onKeydown);
35 + }
36 +
37 + componentWillUnmount() {
38 + document.removeEventListener('keydown', this.onKeydown);
39 + }
40 +
41 + onKeydown = event => {
42 + if (event.metaKey && event.key === 'Enter') {
43 + this.increment();
44 + }
45 + };
46 +
47 + render() {
48 + return (
49 + <React.Fragment>
50 + <Suspense fallback={<div>(display: none)</div>}>
51 + <div>
52 + <AsyncStep text={`${this.state.step} + ${this.id}`} ms={500} />
53 + {this.props.children}
54 + </div>
55 + </Suspense>
56 + <button onClick={this.increment}>Hide</button>
57 + </React.Fragment>
58 + );
59 + }
60 +}
61 +
62 +class SuspendyTree extends React.Component {
63 + parentContainer = React.createRef(null);
64 + container = React.createRef(null);
65 + componentDidMount() {
66 + this.setState({});
67 + document.addEventListener('keydown', this.onKeydown);
68 + }
69 + componentWillUnmount() {
70 + document.removeEventListener('keydown', this.onKeydown);
71 + }
72 + onKeydown = event => {
73 + if (event.metaKey && event.key === '/') {
74 + this.removeAndRestore();
75 + }
76 + };
77 + removeAndRestore = () => {
78 + const parentContainer = this.parentContainer.current;
79 + const container = this.container.current;
80 + parentContainer.removeChild(container);
81 + parentContainer.textContent = '(removed from DOM)';
82 + setTimeout(() => {
83 + parentContainer.textContent = '';
84 + parentContainer.appendChild(container);
85 + }, 500);
86 + };
87 + render() {
88 + return (
89 + <React.Fragment>
90 + <div ref={this.parentContainer}>
91 + <div ref={this.container} />
92 + </div>
93 + <div>
94 + {this.container.current !== null
95 + ? ReactDOM.createPortal(
96 + <React.Fragment>
97 + <SuspendyTreeChild>{this.props.children}</SuspendyTreeChild>
98 + <button onClick={this.removeAndRestore}>Remove</button>
99 + </React.Fragment>,
100 + this.container.current
101 + )
102 + : null}
103 + </div>
104 + </React.Fragment>
105 + );
106 + }
107 +}
108 +
109 +class TextInputFixtures extends React.Component {
110 + render() {
111 + return (
112 + <FixtureSet
113 + title="Suspense"
114 + description="Preserving the state of timed-out children">
115 + <p>
116 + Clicking "Hide" will hide the fixture context using{' '}
117 + <code>display: none</code> for 0.5 seconds, then restore. This is the
118 + built-in behavior for timed-out children. Each fixture tests whether
119 + the state of the DOM is preserved. Clicking "Remove" will remove the
120 + fixture content from the DOM for 0.5 seconds, then restore. This is{' '}
121 + <strong>not</strong> how timed-out children are hidden, but is
122 + included for comparison purposes.
123 + </p>
124 + <div className="footnote">
125 + As a shortcut, you can use Command + Enter (or Control + Enter on
126 + Windows, Linux) to "Hide" all the fixtures, or Command + / to "Remove"
127 + them.
128 + </div>
129 + <TestCase title="Text selection where entire range times out">
130 + <TestCase.Steps>
131 + <li>Use your cursor to select the text below.</li>
132 + <li>Click "Hide" or "Remove".</li>
133 + </TestCase.Steps>
134 +
135 + <TestCase.ExpectedResult>
136 + Text selection is preserved when hiding, but not when removing.
137 + </TestCase.ExpectedResult>
138 +
139 + <Fixture>
140 + <SuspendyTree>
141 + Select this entire sentence (and only this sentence).
142 + </SuspendyTree>
143 + </Fixture>
144 + </TestCase>
145 + <TestCase title="Text selection that extends outside timed-out subtree">
146 + <TestCase.Steps>
147 + <li>
148 + Use your cursor to select a range that includes both the text and
149 + the "Go" button.
150 + </li>
151 + <li>Click "Hide" or "Remove".</li>
152 + </TestCase.Steps>
153 +
154 + <TestCase.ExpectedResult>
155 + Text selection is preserved when hiding, but not when removing.
156 + </TestCase.ExpectedResult>
157 +
158 + <Fixture>
159 + <SuspendyTree>
160 + Select a range that includes both this sentence and the "Go"
161 + button.
162 + </SuspendyTree>
163 + </Fixture>
164 + </TestCase>
165 + <TestCase title="Focus">
166 + <TestCase.Steps>
167 + <li>
168 + Use your cursor to select a range that includes both the text and
169 + the "Go" button.
170 + </li>
171 + <li>
172 + Intead of clicking "Go", which switches focus, press Command +
173 + Enter (or Control + Enter on Windows, Linux).
174 + </li>
175 + </TestCase.Steps>
176 +
177 + <TestCase.ExpectedResult>
178 + The ideal behavior is that the focus would not be lost, but
179 + currently it is (both when hiding and removing).
180 + </TestCase.ExpectedResult>
181 +
182 + <Fixture>
183 + <SuspendyTree>
184 + <button>Focus me</button>
185 + </SuspendyTree>
186 + </Fixture>
187 + </TestCase>
188 + <TestCase title="Uncontrolled form input">
189 + <TestCase.Steps>
190 + <li>Type something ("Hello") into the text input.</li>
191 + <li>Click "Hide" or "Remove".</li>
192 + </TestCase.Steps>
193 +
194 + <TestCase.ExpectedResult>
195 + Input is preserved when hiding, but not when removing.
196 + </TestCase.ExpectedResult>
197 +
198 + <Fixture>
199 + <SuspendyTree>
200 + <input type="text" />
201 + </SuspendyTree>
202 + </Fixture>
203 + </TestCase>
204 + <TestCase title="Image flicker">
205 + <TestCase.Steps>
206 + <li>Click "Hide" or "Remove".</li>
207 + </TestCase.Steps>
208 +
209 + <TestCase.ExpectedResult>
210 + The image should reappear without flickering. The text should not
211 + reflow.
212 + </TestCase.ExpectedResult>
213 +
214 + <Fixture>
215 + <SuspendyTree>
216 + <img src="https://upload.wikimedia.org/wikipedia/commons/e/ee/Atom_%282%29.png" />React
217 + is cool
218 + </SuspendyTree>
219 + </Fixture>
220 + </TestCase>
221 + <TestCase title="Iframe">
222 + <TestCase.Steps>
223 + <li>
224 + The iframe shows a nested version of this fixtures app. Navigate
225 + to the "Text inputs" page.
226 + </li>
227 + <li>Select one of the checkboxes.</li>
228 + <li>Click "Hide" or "Remove".</li>
229 + </TestCase.Steps>
230 +
231 + <TestCase.ExpectedResult>
232 + When removing, the iframe is reloaded. When hiding, the iframe
233 + should still be on the "Text inputs" page. The checkbox should still
234 + be checked. (Unfortunately, scroll position is lost.)
235 + </TestCase.ExpectedResult>
236 +
237 + <Fixture>
238 + <SuspendyTree>
239 + <iframe width="500" height="300" src="/" />
240 + </SuspendyTree>
241 + </Fixture>
242 + </TestCase>
243 + <TestCase title="Video playback">
244 + <TestCase.Steps>
245 + <li>Start playing the video, or seek to a specific position.</li>
246 + <li>Click "Hide" or "Remove".</li>
247 + </TestCase.Steps>
248 +
249 + <TestCase.ExpectedResult>
250 + The playback position should stay the same. When hiding, the video
251 + plays in the background for the entire duration. When removing, the
252 + video stops playing, but the position is not lost.
253 + </TestCase.ExpectedResult>
254 +
255 + <Fixture>
256 + <SuspendyTree>
257 + <video controls>
258 + <source
259 + src="http://techslides.com/demos/sample-videos/small.webm"
260 + type="video/webm"
261 + />
262 + <source
263 + src="http://techslides.com/demos/sample-videos/small.ogv"
264 + type="video/ogg"
265 + />
266 + <source
267 + src="http://techslides.com/demos/sample-videos/small.mp4"
268 + type="video/mp4"
269 + />
270 + <source
271 + src="http://techslides.com/demos/sample-videos/small.3gp"
272 + type="video/3gp"
273 + />
274 + </video>
275 + </SuspendyTree>
276 + </Fixture>
277 + </TestCase>
278 + <TestCase title="Audio playback">
279 + <TestCase.Steps>
280 + <li>Start playing the audio, or seek to a specific position.</li>
281 + <li>Click "Hide" or "Remove".</li>
282 + </TestCase.Steps>
283 +
284 + <TestCase.ExpectedResult>
285 + The playback position should stay the same. When hiding, the audio
286 + plays in the background for the entire duration. When removing, the
287 + audio stops playing, but the position is not lost.
288 + </TestCase.ExpectedResult>
289 + <Fixture>
290 + <SuspendyTree>
291 + <audio controls={true}>
292 + <source src="https://upload.wikimedia.org/wikipedia/commons/e/ec/Mozart_K448.ogg" />
293 + </audio>
294 + </SuspendyTree>
295 + </Fixture>
296 + </TestCase>
297 + <TestCase title="Scroll position">
298 + <TestCase.Steps>
299 + <li>Scroll to a position in the list.</li>
300 + <li>Click "Hide" or "Remove".</li>
301 + </TestCase.Steps>
302 +
303 + <TestCase.ExpectedResult>
304 + Scroll position is preserved when hiding, but not when removing.
305 + </TestCase.ExpectedResult>
306 + <Fixture>
307 + <SuspendyTree>
308 + <div style={{height: 200, overflow: 'scroll'}}>
309 + {Array(20)
310 + .fill()
311 + .map((_, i) => <h2 key={i}>{i + 1}</h2>)}
312 + </div>
313 + </SuspendyTree>
314 + </Fixture>
315 + </TestCase>
316 + </FixtureSet>
317 + );
318 + }
319 +}
320 +
321 +export default TextInputFixtures;
packages/jest-react/src/JestReact.js
+2 -1
@@ -85,6 +85,7 @@ export function toFlushAndThrow(root, ...rest) {
85 }
86
87 export function toMatchRenderedOutput(root, expectedJSX) {
88 + assertYieldsWereCleared(root);
89 const actualJSON = root.toJSON();
90
91 let actualJSX;
@@ -151,7 +152,7 @@ function jsonChildrenToJSXChildren(jsonChildren) {
152 let allJSXChildrenAreStrings = true;
153 let jsxChildrenString = '';
154 for (let i = 0; i < jsonChildren.length; i++) {
154 - const jsxChild = jsonChildrenToJSXChildren(jsonChildren[i]);
155 + const jsxChild = jsonChildToJSXChild(jsonChildren[i]);
156 jsxChildren.push(jsxChild);
157 if (allJSXChildrenAreStrings) {
158 if (typeof jsxChild === 'string') {
packages/react-art/src/ReactARTHostConfig.js
+18
@@ -405,3 +405,21 @@ export function commitUpdate(
405 ) {
406 instance._applyProps(instance, newProps, oldProps);
407 }
408 +
409 +export function hideInstance(instance) {
410 + instance.hide();
411 +}
412 +
413 +export function hideTextInstance(textInstance) {
414 + // Noop
415 +}
416 +
417 +export function unhideInstance(instance, props) {
418 + if (props.visible == null || props.visible) {
419 + instance.show();
420 + }
421 +}
422 +
423 +export function unhideTextInstance(textInstance, text): void {
424 + // Noop
425 +}
packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.internal.js new
+120
@@ -0,0 +1,120 @@
1 +/**
2 + * Copyright (c) 2013-present, Facebook, Inc.
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 + */
9 +
10 +'use strict';
11 +
12 +let ReactFeatureFlags;
13 +let React;
14 +let ReactDOM;
15 +let Suspense;
16 +let ReactCache;
17 +let cache;
18 +let TextResource;
19 +
20 +describe('ReactDOMSuspensePlaceholder', () => {
21 + let container;
22 +
23 + beforeEach(() => {
24 + jest.resetModules();
25 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
26 + ReactFeatureFlags.enableSuspense = true;
27 + React = require('react');
28 + ReactDOM = require('react-dom');
29 + ReactCache = require('react-cache');
30 + Suspense = React.unstable_Suspense;
31 + container = document.createElement('div');
32 +
33 + function invalidateCache() {
34 + cache = ReactCache.createCache(invalidateCache);
35 + }
36 + invalidateCache();
37 + TextResource = ReactCache.createResource(([text, ms = 0]) => {
38 + return new Promise((resolve, reject) =>
39 + setTimeout(() => {
40 + resolve(text);
41 + }, ms),
42 + );
43 + }, ([text, ms]) => text);
44 + });
45 +
46 + function advanceTimers(ms) {
47 + // Note: This advances Jest's virtual time but not React's. Use
48 + // ReactNoop.expire for that.
49 + if (typeof ms !== 'number') {
50 + throw new Error('Must specify ms');
51 + }
52 + jest.advanceTimersByTime(ms);
53 + // Wait until the end of the current tick
54 + return new Promise(resolve => {
55 + setImmediate(resolve);
56 + });
57 + }
58 +
59 + function Text(props) {
60 + return props.text;
61 + }
62 +
63 + function AsyncText(props) {
64 + const text = props.text;
65 + TextResource.read(cache, [props.text, props.ms]);
66 + return text;
67 + }
68 +
69 + it('hides and unhides timed out DOM elements', async () => {
70 + let divs = [
71 + React.createRef(null),
72 + React.createRef(null),
73 + React.createRef(null),
74 + ];
75 + function App() {
76 + return (
77 + <Suspense maxDuration={500} fallback={<Text text="Loading..." />}>
78 + <div ref={divs[0]}>
79 + <Text text="A" />
80 + </div>
81 + <div ref={divs[1]}>
82 + <AsyncText ms={1000} text="B" />
83 + </div>
84 + <div style={{display: 'block'}} ref={divs[2]}>
85 + <Text text="C" />
86 + </div>
87 + </Suspense>
88 + );
89 + }
90 + ReactDOM.render(<App />, container);
91 + expect(divs[0].current.style.display).toEqual('none');
92 + expect(divs[1].current.style.display).toEqual('none');
93 + expect(divs[2].current.style.display).toEqual('none');
94 +
95 + await advanceTimers(1000);
96 +
97 + expect(divs[0].current.style.display).toEqual('');
98 + expect(divs[1].current.style.display).toEqual('');
99 + // This div's display was set with a prop.
100 + expect(divs[2].current.style.display).toEqual('block');
101 + });
102 +
103 + it('hides and unhides timed out text nodes', async () => {
104 + function App() {
105 + return (
106 + <Suspense maxDuration={500} fallback={<Text text="Loading..." />}>
107 + <Text text="A" />
108 + <AsyncText ms={1000} text="B" />
109 + <Text text="C" />
110 + </Suspense>
111 + );
112 + }
113 + ReactDOM.render(<App />, container);
114 + expect(container.textContent).toEqual('Loading...');
115 +
116 + await advanceTimers(1000);
117 +
118 + expect(container.textContent).toEqual('ABC');
119 + });
120 +});
packages/react-dom/src/client/ReactDOMHostConfig.js
+36
@@ -45,6 +45,9 @@ export type Props = {
45 hidden?: boolean,
46 suppressHydrationWarning?: boolean,
47 dangerouslySetInnerHTML?: mixed,
48 + style?: {
49 + display?: string,
50 + },
51 };
52 export type Container = Element | Document;
53 export type Instance = Element;
@@ -73,6 +76,8 @@ if (__DEV__) {
76 SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning';
77 }
78
79 +const STYLE = 'style';
80 +
81 let eventsEnabled: ?boolean = null;
82 let selectionInformation: ?mixed = null;
83
@@ -411,6 +416,37 @@ export function removeChildFromContainer(
416 }
417 }
418
419 +export function hideInstance(instance: Instance): void {
420 + // TODO: Does this work for all element types? What about MathML? Should we
421 + // pass host context to this method?
422 + instance = ((instance: any): HTMLElement);
423 + instance.style.display = 'none';
424 +}
425 +
426 +export function hideTextInstance(textInstance: TextInstance): void {
427 + textInstance.nodeValue = '';
428 +}
429 +
430 +export function unhideInstance(instance: Instance, props: Props): void {
431 + instance = ((instance: any): HTMLElement);
432 + const styleProp = props[STYLE];
433 + const display =
434 + styleProp !== undefined &&
435 + styleProp !== null &&
436 + styleProp.hasOwnProperty('display')
437 + ? styleProp.display
438 + : null;
439 + // $FlowFixMe Setting a style property to null is the valid way to reset it.
440 + instance.style.display = display;
441 +}
442 +
443 +export function unhideTextInstance(
444 + textInstance: TextInstance,
445 + text: string,
446 +): void {
447 + textInstance.nodeValue = text;
448 +}
449 +
450 // -------------------
451 // Hydration
452 // -------------------
packages/react-native-renderer/src/ReactFabricHostConfig.js
+27
@@ -374,6 +374,33 @@ export function cloneInstance(
374 };
375 }
376
377 +export function cloneHiddenInstance(
378 + instance: Instance,
379 + type: string,
380 + props: Props,
381 + internalInstanceHandle: Object,
382 +): Instance {
383 + throw new Error('Not yet implemented.');
384 +}
385 +
386 +export function cloneUnhiddenInstance(
387 + instance: Instance,
388 + type: string,
389 + props: Props,
390 + internalInstanceHandle: Object,
391 +): Instance {
392 + throw new Error('Not yet implemented.');
393 +}
394 +
395 +export function createHiddenTextInstance(
396 + text: string,
397 + rootContainerInstance: Container,
398 + hostContext: HostContext,
399 + internalInstanceHandle: Object,
400 +): TextInstance {
401 + throw new Error('Not yet implemented.');
402 +}
403 +
404 export function createContainerChildSet(container: Container): ChildSet {
405 return createChildNodeSet(container);
406 }
packages/react-native-renderer/src/ReactNativeHostConfig.js
+19
@@ -451,3 +451,22 @@ export function removeChildFromContainer(
451 export function resetTextContent(instance: Instance): void {
452 // Noop
453 }
454 +
455 +export function hideInstance(instance: Instance): void {
456 + throw new Error('Not yet implemented.');
457 +}
458 +
459 +export function hideTextInstance(textInstance: TextInstance): void {
460 + throw new Error('Not yet implemented.');
461 +}
462 +
463 +export function unhideInstance(instance: Instance, props: Props): void {
464 + throw new Error('Not yet implemented.');
465 +}
466 +
467 +export function unhideTextInstance(
468 + textInstance: TextInstance,
469 + text: string,
470 +): void {
471 + throw new Error('Not yet implemented.');
472 +}
packages/react-noop-renderer/src/createReactNoop.js
+213 -36
@@ -20,16 +20,22 @@ import type {ReactNodeList} from 'shared/ReactTypes';
20
21 import * as ReactPortal from 'shared/ReactPortal';
22 import expect from 'expect';
23 +import {REACT_FRAGMENT_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
24
24 -type Container = {rootID: string, children: Array<Instance | TextInstance>};
25 -type Props = {prop: any, hidden?: boolean, children?: mixed};
25 +type Container = {
26 + rootID: string,
27 + children: Array<Instance | TextInstance>,
28 +};
29 +type Props = {prop: any, hidden: boolean, children?: mixed};
30 type Instance = {|
31 type: string,
32 id: number,
33 children: Array<Instance | TextInstance>,
34 + text: string | null,
35 prop: any,
36 + hidden: boolean,
37 |};
32 -type TextInstance = {|text: string, id: number|};
38 +type TextInstance = {|text: string, id: number, hidden: boolean|};
39
40 const NO_CONTEXT = {};
41 const UPDATE_SIGNAL = {};
@@ -164,6 +170,47 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
170 removeChildFromContainerOrInstance(parentInstance, child);
171 }
172
173 + function cloneInstance(
174 + instance: Instance,
175 + updatePayload: null | Object,
176 + type: string,
177 + oldProps: Props,
178 + newProps: Props,
179 + internalInstanceHandle: Object,
180 + keepChildren: boolean,
181 + recyclableInstance: null | Instance,
182 + ): Instance {
183 + const clone = {
184 + id: instance.id,
185 + type: type,
186 + children: keepChildren ? instance.children : [],
187 + text: shouldSetTextContent(type, newProps)
188 + ? (newProps.children: any) + ''
189 + : null,
190 + prop: newProps.prop,
191 + hidden: newProps.hidden === true,
192 + };
193 + Object.defineProperty(clone, 'id', {
194 + value: clone.id,
195 + enumerable: false,
196 + });
197 + Object.defineProperty(clone, 'text', {
198 + value: clone.text,
199 + enumerable: false,
200 + });
201 + hostCloneCounter++;
202 + return clone;
203 + }
204 +
205 + function shouldSetTextContent(type: string, props: Props): boolean {
206 + if (type === 'errorInBeginPhase') {
207 + throw new Error('Error in host config.');
208 + }
209 + return (
210 + typeof props.children === 'string' || typeof props.children === 'number'
211 + );
212 + }
213 +
214 let elapsedTimeInMs = 0;
215
216 const sharedHostConfig = {
@@ -187,10 +234,18 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
234 id: instanceCounter++,
235 type: type,
236 children: [],
237 + text: shouldSetTextContent(type, props)
238 + ? (props.children: any) + ''
239 + : null,
240 prop: props.prop,
241 + hidden: props.hidden === true,
242 };
243 // Hide from unit tests
244 Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false});
245 + Object.defineProperty(inst, 'text', {
246 + value: inst.text,
247 + enumerable: false,
248 + });
249 return inst;
250 },
251
@@ -228,14 +283,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
283 return UPDATE_SIGNAL;
284 },
285
231 - shouldSetTextContent(type: string, props: Props): boolean {
232 - if (type === 'errorInBeginPhase') {
233 - throw new Error('Error in host config.');
234 - }
235 - return (
236 - typeof props.children === 'string' || typeof props.children === 'number'
237 - );
238 - },
286 + shouldSetTextContent,
287
288 shouldDeprioritizeSubtree(type: string, props: Props): boolean {
289 return !!props.hidden;
@@ -247,7 +295,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
295 hostContext: Object,
296 internalInstanceHandle: Object,
297 ): TextInstance {
250 - const inst = {text: text, id: instanceCounter++};
298 + const inst = {text: text, id: instanceCounter++, hidden: false};
299 // Hide from unit tests
300 Object.defineProperty(inst, 'id', {value: inst.id, enumerable: false});
301 return inst;
@@ -324,6 +372,10 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
372 }
373 hostUpdateCounter++;
374 instance.prop = newProps.prop;
375 + instance.hidden = newProps.hidden === true;
376 + if (shouldSetTextContent(type, newProps)) {
377 + instance.text = (newProps.children: any) + '';
378 + }
379 },
380
381 commitTextUpdate(
@@ -342,36 +394,34 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
394 removeChild,
395 removeChildFromContainer,
396
345 - resetTextContent(instance: Instance): void {},
397 + hideInstance(instance: Instance): void {
398 + instance.hidden = true;
399 + },
400 +
401 + hideTextInstance(textInstance: TextInstance): void {
402 + textInstance.hidden = true;
403 + },
404 +
405 + unhideInstance(instance: Instance, props: Props): void {
406 + if (!props.hidden) {
407 + instance.hidden = false;
408 + }
409 + },
410 +
411 + unhideTextInstance(textInstance: TextInstance, text: string): void {
412 + textInstance.hidden = false;
413 + },
414 +
415 + resetTextContent(instance: Instance): void {
416 + instance.text = null;
417 + },
418 }
419 : {
420 ...sharedHostConfig,
421 supportsMutation: false,
422 supportsPersistence: true,
423
352 - cloneInstance(
353 - instance: Instance,
354 - updatePayload: null | Object,
355 - type: string,
356 - oldProps: Props,
357 - newProps: Props,
358 - internalInstanceHandle: Object,
359 - keepChildren: boolean,
360 - recyclableInstance: null | Instance,
361 - ): Instance {
362 - const clone = {
363 - id: instance.id,
364 - type: type,
365 - children: keepChildren ? instance.children : [],
366 - prop: newProps.prop,
367 - };
368 - Object.defineProperty(clone, 'id', {
369 - value: clone.id,
370 - enumerable: false,
371 - });
372 - hostCloneCounter++;
373 - return clone;
374 - },
424 + cloneInstance,
425
426 createContainerChildSet(
427 container: Container,
@@ -397,6 +447,61 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
447 ): void {
448 container.children = newChildren;
449 },
450 +
451 + cloneHiddenInstance(
452 + instance: Instance,
453 + type: string,
454 + props: Props,
455 + internalInstanceHandle: Object,
456 + ): Instance {
457 + const clone = cloneInstance(
458 + instance,
459 + null,
460 + type,
461 + props,
462 + props,
463 + internalInstanceHandle,
464 + true,
465 + null,
466 + );
467 + clone.hidden = true;
468 + return clone;
469 + },
470 +
471 + cloneUnhiddenInstance(
472 + instance: Instance,
473 + type: string,
474 + props: Props,
475 + internalInstanceHandle: Object,
476 + ): Instance {
477 + const clone = cloneInstance(
478 + instance,
479 + null,
480 + type,
481 + props,
482 + props,
483 + internalInstanceHandle,
484 + true,
485 + null,
486 + );
487 + clone.hidden = props.hidden;
488 + return clone;
489 + },
490 +
491 + createHiddenTextInstance(
492 + text: string,
493 + rootContainerInstance: Container,
494 + hostContext: Object,
495 + internalInstanceHandle: Object,
496 + ): TextInstance {
497 + const inst = {text: text, id: instanceCounter++, hidden: true};
498 + // Hide from unit tests
499 + Object.defineProperty(inst, 'id', {
500 + value: inst.id,
501 + enumerable: false,
502 + });
503 + return inst;
504 + },
505 };
506
507 const NoopRenderer = reconciler(hostConfig);
@@ -439,6 +544,59 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
544 }
545 }
546
547 + function childToJSX(child, text) {
548 + if (text !== null) {
549 + return text;
550 + }
551 + if (child === null) {
552 + return null;
553 + }
554 + if (typeof child === 'string') {
555 + return child;
556 + }
557 + if (Array.isArray(child)) {
558 + if (child.length === 0) {
559 + return null;
560 + }
561 + if (child.length === 1) {
562 + return childToJSX(child[0], null);
563 + }
564 + // $FlowFixMe
565 + const children = child.map(c => childToJSX(c, null));
566 + if (children.every(c => typeof c === 'string' || typeof c === 'number')) {
567 + return children.join('');
568 + }
569 + return children;
570 + }
571 + if (Array.isArray(child.children)) {
572 + // This is an instance.
573 + const instance: Instance = (child: any);
574 + const children = childToJSX(instance.children, instance.text);
575 + const props = ({prop: instance.prop}: any);
576 + if (instance.hidden) {
577 + props.hidden = true;
578 + }
579 + if (children !== null) {
580 + props.children = children;
581 + }
582 + return {
583 + $$typeof: REACT_ELEMENT_TYPE,
584 + type: instance.type,
585 + key: null,
586 + ref: null,
587 + props: props,
588 + _owner: null,
589 + _store: __DEV__ ? {} : undefined,
590 + };
591 + }
592 + // This is a text instance
593 + const textInstance: TextInstance = (child: any);
594 + if (textInstance.hidden) {
595 + return '';
596 + }
597 + return textInstance.text;
598 + }
599 +
600 const ReactNoop = {
601 getChildren(rootID: string = DEFAULT_ROOT_ID) {
602 const container = rootContainers.get(rootID);
@@ -463,6 +621,25 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
621 return root.current.stateNode.containerInfo;
622 },
623
624 + getChildrenAsJSX(rootID: string = DEFAULT_ROOT_ID) {
625 + const children = childToJSX(ReactNoop.getChildren(rootID), null);
626 + if (children === null) {
627 + return null;
628 + }
629 + if (Array.isArray(children)) {
630 + return {
631 + $$typeof: REACT_ELEMENT_TYPE,
632 + type: REACT_FRAGMENT_TYPE,
633 + key: null,
634 + ref: null,
635 + props: {children},
636 + _owner: null,
637 + _store: __DEV__ ? {} : undefined,
638 + };
639 + }
640 + return children;
641 + },
642 +
643 createPortal(
644 children: ReactNodeList,
645 container: Container,
packages/react-reconciler/src/ReactFiberBeginWork.js
+231 -54
@@ -11,6 +11,8 @@ import type {ReactProviderType, ReactContext} from 'shared/ReactTypes';
11 import type {Fiber} from 'react-reconciler/src/ReactFiber';
12 import type {FiberRoot} from './ReactFiberRoot';
13 import type {ExpirationTime} from './ReactFiberExpirationTime';
14 +import type {SuspenseState} from './ReactFiberSuspenseComponent';
15 +
16 import checkPropTypes from 'prop-types/checkPropTypes';
17
18 import {
@@ -103,7 +105,11 @@ import {
105 } from './ReactFiberClassComponent';
106 import {readLazyComponentType} from './ReactFiberLazyComponent';
107 import {getResultFromResolvedThenable} from 'shared/ReactLazyComponent';
106 -import {resolveLazyComponentTag} from './ReactFiber';
108 +import {
109 + resolveLazyComponentTag,
110 + createFiberFromFragment,
111 + createWorkInProgress,
112 +} from './ReactFiber';
113
114 const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
115
@@ -702,17 +708,22 @@ function resolveDefaultProps(Component, baseProps) {
708 }
709
710 function mountIndeterminateComponent(
705 - current,
711 + _current,
712 workInProgress,
713 Component,
714 updateExpirationTime,
715 renderExpirationTime,
716 ) {
711 - invariant(
712 - current === null,
713 - 'An indeterminate component should never have mounted. This error is ' +
714 - 'likely caused by a bug in React. Please file an issue.',
715 - );
717 + if (_current !== null) {
718 + // An indeterminate component only mounts if it suspended inside a non-
719 + // concurrent tree, in an inconsistent state. We want to tree it like
720 + // a new mount, even though an empty version of it already committed.
721 + // Disconnect the alternate pointers.
722 + _current.alternate = null;
723 + workInProgress.alternate = null;
724 + // Since this is conceptually a new fiber, schedule a Placement effect
725 + workInProgress.effectTag |= Placement;
726 + }
727
728 const props = workInProgress.pendingProps;
729 if (
@@ -734,7 +745,7 @@ function mountIndeterminateComponent(
745 switch (resolvedTag) {
746 case FunctionComponentLazy: {
747 child = updateFunctionComponent(
737 - current,
748 + null,
749 workInProgress,
750 Component,
751 resolvedProps,
@@ -744,7 +755,7 @@ function mountIndeterminateComponent(
755 }
756 case ClassComponentLazy: {
757 child = updateClassComponent(
747 - current,
758 + null,
759 workInProgress,
760 Component,
761 resolvedProps,
@@ -754,7 +765,7 @@ function mountIndeterminateComponent(
765 }
766 case ForwardRefLazy: {
767 child = updateForwardRef(
757 - current,
768 + null,
769 workInProgress,
770 Component,
771 resolvedProps,
@@ -764,7 +775,7 @@ function mountIndeterminateComponent(
775 }
776 case PureComponentLazy: {
777 child = updatePureComponent(
767 - current,
778 + null,
779 workInProgress,
780 Component,
781 resolvedProps,
@@ -863,7 +874,7 @@ function mountIndeterminateComponent(
874 adoptClassInstance(workInProgress, value);
875 mountClassInstance(workInProgress, Component, props, renderExpirationTime);
876 return finishClassComponent(
866 - current,
877 + null,
878 workInProgress,
879 Component,
880 true,
@@ -933,7 +944,7 @@ function mountIndeterminateComponent(
944 }
945 }
946 }
936 - reconcileChildren(current, workInProgress, value, renderExpirationTime);
947 + reconcileChildren(null, workInProgress, value, renderExpirationTime);
948 memoizeProps(workInProgress, props);
949 return workInProgress.child;
950 }
@@ -944,36 +955,43 @@ function updateSuspenseComponent(
955 workInProgress,
956 renderExpirationTime,
957 ) {
958 + const mode = workInProgress.mode;
959 const nextProps = workInProgress.pendingProps;
960
949 - // Check if we already attempted to render the normal state. If we did,
950 - // and we timed out, render the placeholder state.
951 - const alreadyCaptured = (workInProgress.effectTag & DidCapture) === NoEffect;
952 -
961 + // We should attempt to render the primary children unless this boundary
962 + // already suspended during this render (`alreadyCaptured` is true).
963 + let nextState: SuspenseState | null = workInProgress.memoizedState;
964 let nextDidTimeout;
954 - if (current !== null && workInProgress.updateQueue !== null) {
955 - // We're outside strict mode. Something inside this Placeholder boundary
956 - // suspended during the last commit. Switch to the placholder.
957 - workInProgress.updateQueue = null;
958 - nextDidTimeout = true;
965 + if (nextState === null) {
966 + // An empty suspense state means this boundary has not yet timed out.
967 + nextDidTimeout = false;
968 } else {
960 - nextDidTimeout = !alreadyCaptured;
961 - }
962 -
963 - if ((workInProgress.mode & StrictMode) !== NoEffect) {
964 - if (nextDidTimeout) {
965 - // If the timed-out view commits, schedule an update effect to record
966 - // the committed time.
967 - workInProgress.effectTag |= Update;
969 + if (!nextState.alreadyCaptured) {
970 + // Since we haven't already suspended during this commit, clear the
971 + // existing suspense state. We'll try rendering again.
972 + nextDidTimeout = false;
973 + nextState = null;
974 } else {
969 - // The state node points to the time at which placeholder timed out.
970 - // We can clear it once we switch back to the normal children.
971 - workInProgress.stateNode = null;
975 + // Something in this boundary's subtree already suspended. Switch to
976 + // rendering the fallback children. Set `alreadyCaptured` to true.
977 + nextDidTimeout = true;
978 + if (current !== null && nextState === current.memoizedState) {
979 + // Create a new suspense state to avoid mutating the current tree's.
980 + nextState = {
981 + alreadyCaptured: true,
982 + didTimeout: true,
983 + timedOutAt: nextState.timedOutAt,
984 + };
985 + } else {
986 + // Already have a clone, so it's safe to mutate.
987 + nextState.alreadyCaptured = true;
988 + nextState.didTimeout = true;
989 + }
990 }
991 }
992
993 // If the `children` prop is a function, treat it like a render prop.
976 - // TODO: This is temporary until we finalize a lower level API.
994 + // TODO: Remove this, or put it behind a feature flag
995 const children = nextProps.children;
996 let nextChildren;
997 if (typeof children === 'function') {
@@ -982,29 +1000,170 @@ function updateSuspenseComponent(
1000 nextChildren = nextDidTimeout ? nextProps.fallback : children;
1001 }
1002
985 - if (current !== null && nextDidTimeout !== workInProgress.memoizedState) {
986 - // We're about to switch from the placeholder children to the normal
987 - // children, or vice versa. These are two different conceptual sets that
988 - // happen to be stored in the same set. Call this special function to
989 - // force the new set not to match with the current set.
990 - // TODO: The proper way to model this is by storing each set separately.
991 - forceUnmountCurrentAndReconcile(
992 - current,
993 - workInProgress,
994 - nextChildren,
995 - renderExpirationTime,
996 - );
1003 + // This next part is a bit confusing. If the children timeout, we switch to
1004 + // showing the fallback children in place of the "primary" children.
1005 + // However, we don't want to delete the primary children because then their
1006 + // state will be lost (both the React state and the host state, e.g.
1007 + // uncontrolled form inputs). Instead we keep them mounted and hide them.
1008 + // Both the fallback children AND the primary children are rendered at the
1009 + // same time. Once the primary children are un-suspended, we can delete
1010 + // the fallback children — don't need to preserve their state.
1011 + //
1012 + // The two sets of children are siblings in the host environment, but
1013 + // semantically, for purposes of reconciliation, they are two separate sets.
1014 + // So we store them using two fragment fibers.
1015 + //
1016 + // However, we want to avoid allocating extra fibers for every placeholder.
1017 + // They're only necessary when the children time out, because that's the
1018 + // only time when both sets are mounted.
1019 + //
1020 + // So, the extra fragment fibers are only used if the children time out.
1021 + // Otherwise, we render the primary children directly. This requires some
1022 + // custom reconciliation logic to preserve the state of the primary
1023 + // children. It's essentially a very basic form of re-parenting.
1024 +
1025 + // `child` points to the child fiber. In the normal case, this is the first
1026 + // fiber of the primary children set. In the timed-out case, it's a
1027 + // a fragment fiber containing the primary children.
1028 + let child;
1029 + // `next` points to the next fiber React should render. In the normal case,
1030 + // it's the same as `child`: the first fiber of the primary children set.
1031 + // In the timed-out case, it's a fragment fiber containing the *fallback*
1032 + // children -- we skip over the primary children entirely.
1033 + let next;
1034 + if (current === null) {
1035 + // This is the initial mount. This branch is pretty simple because there's
1036 + // no previous state that needs to be preserved.
1037 + if (nextDidTimeout) {
1038 + // Mount separate fragments for primary and fallback children.
1039 + const primaryChildFragment = createFiberFromFragment(
1040 + null,
1041 + mode,
1042 + NoWork,
1043 + null,
1044 + );
1045 + const fallbackChildFragment = createFiberFromFragment(
1046 + nextChildren,
1047 + mode,
1048 + renderExpirationTime,
1049 + null,
1050 + );
1051 + primaryChildFragment.sibling = fallbackChildFragment;
1052 + child = primaryChildFragment;
1053 + // Skip the primary children, and continue working on the
1054 + // fallback children.
1055 + next = fallbackChildFragment;
1056 + child.return = next.return = workInProgress;
1057 + } else {
1058 + // Mount the primary children without an intermediate fragment fiber.
1059 + child = next = mountChildFibers(
1060 + workInProgress,
1061 + null,
1062 + nextChildren,
1063 + renderExpirationTime,
1064 + );
1065 + }
1066 } else {
998 - reconcileChildren(
999 - current,
1000 - workInProgress,
1001 - nextChildren,
1002 - renderExpirationTime,
1003 - );
1067 + // This is an update. This branch is more complicated because we need to
1068 + // ensure the state of the primary children is preserved.
1069 + const prevState = current.memoizedState;
1070 + const prevDidTimeout = prevState !== null && prevState.didTimeout;
1071 + if (prevDidTimeout) {
1072 + // The current tree already timed out. That means each child set is
1073 + // wrapped in a fragment fiber.
1074 + const currentPrimaryChildFragment: Fiber = (current.child: any);
1075 + const currentFallbackChildFragment: Fiber = (currentPrimaryChildFragment.sibling: any);
1076 + if (nextDidTimeout) {
1077 + // Still timed out. Reuse the current primary children by cloning
1078 + // its fragment. We're going to skip over these entirely.
1079 + const primaryChildFragment = createWorkInProgress(
1080 + currentFallbackChildFragment,
1081 + currentFallbackChildFragment.pendingProps,
1082 + NoWork,
1083 + );
1084 + primaryChildFragment.effectTag |= Placement;
1085 + // Clone the fallback child fragment, too. These we'll continue
1086 + // working on.
1087 + const fallbackChildFragment = (primaryChildFragment.sibling = createWorkInProgress(
1088 + currentFallbackChildFragment,
1089 + nextChildren,
1090 + currentFallbackChildFragment.expirationTime,
1091 + ));
1092 + fallbackChildFragment.effectTag |= Placement;
1093 + child = primaryChildFragment;
1094 + // Skip the primary children, and continue working on the
1095 + // fallback children.
1096 + next = fallbackChildFragment;
1097 + child.return = next.return = workInProgress;
1098 + } else {
1099 + // No longer suspended. Switch back to showing the primary children,
1100 + // and remove the intermediate fragment fiber.
1101 + const currentPrimaryChild = currentPrimaryChildFragment.child;
1102 + const currentFallbackChild = currentFallbackChildFragment.child;
1103 + const primaryChild = reconcileChildFibers(
1104 + workInProgress,
1105 + currentPrimaryChild,
1106 + nextChildren,
1107 + renderExpirationTime,
1108 + );
1109 + // Delete the fallback children.
1110 + reconcileChildFibers(
1111 + workInProgress,
1112 + currentFallbackChild,
1113 + null,
1114 + renderExpirationTime,
1115 + );
1116 + // Continue rendering the children, like we normally do.
1117 + child = next = primaryChild;
1118 + }
1119 + } else {
1120 + // The current tree has not already timed out. That means the primary
1121 + // children are not wrapped in a fragment fiber.
1122 + const currentPrimaryChild: Fiber = (current.child: any);
1123 + if (nextDidTimeout) {
1124 + // Timed out. Wrap the children in a fragment fiber to keep them
1125 + // separate from the fallback children.
1126 + const primaryChildFragment = createFiberFromFragment(
1127 + // It shouldn't matter what the pending props are because we aren't
1128 + // going to render this fragment.
1129 + null,
1130 + mode,
1131 + NoWork,
1132 + null,
1133 + );
1134 + primaryChildFragment.effectTag |= Placement;
1135 + primaryChildFragment.child = currentPrimaryChild;
1136 + currentPrimaryChild.return = primaryChildFragment;
1137 + // Create a fragment from the fallback children, too.
1138 + const fallbackChildFragment = (primaryChildFragment.sibling = createFiberFromFragment(
1139 + nextChildren,
1140 + mode,
1141 + renderExpirationTime,
1142 + null,
1143 + ));
1144 + fallbackChildFragment.effectTag |= Placement;
1145 + child = primaryChildFragment;
1146 + // Skip the primary children, and continue working on the
1147 + // fallback children.
1148 + next = fallbackChildFragment;
1149 + child.return = next.return = workInProgress;
1150 + } else {
1151 + // Still haven't timed out. Continue rendering the children, like we
1152 + // normally do.
1153 + next = child = reconcileChildFibers(
1154 + workInProgress,
1155 + currentPrimaryChild,
1156 + nextChildren,
1157 + renderExpirationTime,
1158 + );
1159 + }
1160 + }
1161 }
1162 +
1163 workInProgress.memoizedProps = nextProps;
1006 - workInProgress.memoizedState = nextDidTimeout;
1007 - return workInProgress.child;
1164 + workInProgress.memoizedState = nextState;
1165 + workInProgress.child = child;
1166 + return next;
1167 }
1168
1169 function updatePortalComponent(
@@ -1290,6 +1449,24 @@ function beginWork(
1449 workInProgress.effectTag |= Update;
1450 }
1451 break;
1452 + case SuspenseComponent: {
1453 + const child = bailoutOnAlreadyFinishedWork(
1454 + current,
1455 + workInProgress,
1456 + renderExpirationTime,
1457 + );
1458 + if (child !== null) {
1459 + const nextState = workInProgress.memoizedState;
1460 + const nextDidTimeout = nextState !== null && nextState.didTimeout;
1461 + if (nextDidTimeout) {
1462 + return child.sibling;
1463 + } else {
1464 + return child;
1465 + }
1466 + } else {
1467 + return null;
1468 + }
1469 + }
1470 }
1471 return bailoutOnAlreadyFinishedWork(
1472 current,
packages/react-reconciler/src/ReactFiberCommitWork.js
+84 -16
@@ -18,6 +18,7 @@ import type {Fiber} from './ReactFiber';
18 import type {FiberRoot} from './ReactFiberRoot';
19 import type {ExpirationTime} from './ReactFiberExpirationTime';
20 import type {CapturedValue, CapturedError} from './ReactCapturedValue';
21 +import type {SuspenseState} from './ReactFiberSuspenseComponent';
22
23 import {
24 enableSchedulerTracing,
@@ -39,17 +40,17 @@ import {
40 clearCaughtError,
41 } from 'shared/ReactErrorUtils';
42 import {
42 - NoEffect,
43 ContentReset,
44 Placement,
45 Snapshot,
46 Update,
47 + Callback,
48 } from 'shared/ReactSideEffectTags';
49 import getComponentName from 'shared/getComponentName';
50 import invariant from 'shared/invariant';
51 import warningWithoutStack from 'shared/warningWithoutStack';
52
52 -import {Sync} from './ReactFiberExpirationTime';
53 +import {NoWork, Sync} from './ReactFiberExpirationTime';
54 import {onCommitUnmount} from './ReactFiberDevToolsHook';
55 import {startPhaseTimer, stopPhaseTimer} from './ReactDebugFiberPerf';
56 import {getStackByFiberInDevAndProd} from './ReactCurrentFiber';
@@ -72,15 +73,16 @@ import {
73 removeChildFromContainer,
74 replaceContainerChildren,
75 createContainerChildSet,
76 + hideInstance,
77 + hideTextInstance,
78 + unhideInstance,
79 + unhideTextInstance,
80 } from './ReactFiberHostConfig';
81 import {
82 captureCommitPhaseError,
83 requestCurrentTime,
84 scheduleWork,
85 } from './ReactFiberScheduler';
81 -import {StrictMode} from './ReactTypeOfMode';
82 -
83 -const emptyObject = {};
86
87 let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null;
88 if (__DEV__) {
@@ -352,19 +354,45 @@ function commitLifeCycles(
354 return;
355 }
356 case SuspenseComponent: {
355 - if ((finishedWork.mode & StrictMode) === NoEffect) {
356 - // In loose mode, a placeholder times out by scheduling a synchronous
357 - // update in the commit phase. Use `updateQueue` field to signal that
358 - // the Timeout needs to switch to the placeholder. We don't need an
359 - // entire queue. Any non-null value works.
360 - // $FlowFixMe - Intentionally using a value other than an UpdateQueue.
361 - finishedWork.updateQueue = emptyObject;
357 + if (finishedWork.effectTag & Callback) {
358 + // In non-strict mode, a suspense boundary times out by commiting
359 + // twice: first, by committing the children in an inconsistent state,
360 + // then hiding them and showing the fallback children in a subsequent
361 + // commit.
362 + const newState: SuspenseState = {
363 + alreadyCaptured: true,
364 + didTimeout: false,
365 + timedOutAt: NoWork,
366 + };
367 + finishedWork.memoizedState = newState;
368 scheduleWork(finishedWork, Sync);
369 + return;
370 + }
371 + let oldState: SuspenseState | null =
372 + current !== null ? current.memoizedState : null;
373 + let newState: SuspenseState | null = finishedWork.memoizedState;
374 + let oldDidTimeout = oldState !== null ? oldState.didTimeout : false;
375 +
376 + let newDidTimeout;
377 + let primaryChildParent = finishedWork;
378 + if (newState === null) {
379 + newDidTimeout = false;
380 } else {
364 - // In strict mode, the Update effect is used to record the time at
365 - // which the placeholder timed out.
366 - const currentTime = requestCurrentTime();
367 - finishedWork.stateNode = {timedOutAt: currentTime};
381 + newDidTimeout = newState.didTimeout;
382 + if (newDidTimeout) {
383 + primaryChildParent = finishedWork.child;
384 + newState.alreadyCaptured = false;
385 + if (newState.timedOutAt === NoWork) {
386 + // If the children had not already timed out, record the time.
387 + // This is used to compute the elapsed time during subsequent
388 + // attempts to render the children.
389 + newState.timedOutAt = requestCurrentTime();
390 + }
391 + }
392 + }
393 +
394 + if (newDidTimeout !== oldDidTimeout && primaryChildParent !== null) {
395 + hideOrUnhideAllChildren(primaryChildParent, newDidTimeout);
396 }
397 return;
398 }
@@ -378,6 +406,46 @@ function commitLifeCycles(
406 }
407 }
408
409 +function hideOrUnhideAllChildren(finishedWork, isHidden) {
410 + if (supportsMutation) {
411 + // We only have the top Fiber that was inserted but we need recurse down its
412 + // children to find all the terminal nodes.
413 + let node: Fiber = finishedWork;
414 + while (true) {
415 + if (node.tag === HostComponent) {
416 + const instance = node.stateNode;
417 + if (isHidden) {
418 + hideInstance(instance);
419 + } else {
420 + unhideInstance(node.stateNode, node.memoizedProps);
421 + }
422 + } else if (node.tag === HostText) {
423 + const instance = node.stateNode;
424 + if (isHidden) {
425 + hideTextInstance(instance);
426 + } else {
427 + unhideTextInstance(instance, node.memoizedProps);
428 + }
429 + } else if (node.child !== null) {
430 + node.child.return = node;
431 + node = node.child;
432 + continue;
433 + }
434 + if (node === finishedWork) {
435 + return;
436 + }
437 + while (node.sibling === null) {
438 + if (node.return === null || node.return === finishedWork) {
439 + return;
440 + }
441 + node = node.return;
442 + }
443 + node.sibling.return = node.return;
444 + node = node.sibling;
445 + }
446 + }
447 +}
448 +
449 function commitAttachRef(finishedWork: Fiber) {
450 const ref = finishedWork.ref;
451 if (ref !== null) {
packages/react-reconciler/src/ReactFiberCompleteWork.js
+234 -45
@@ -17,6 +17,7 @@ import type {
17 Container,
18 ChildSet,
19 } from './ReactFiberHostConfig';
20 +import type {SuspenseState} from './ReactFiberSuspenseComponent';
21
22 import {
23 IndeterminateComponent,
@@ -46,12 +47,15 @@ import {getResultFromResolvedThenable} from 'shared/ReactLazyComponent';
47 import {
48 createInstance,
49 createTextInstance,
50 + createHiddenTextInstance,
51 appendInitialChild,
52 finalizeInitialChildren,
53 prepareUpdate,
54 supportsMutation,
55 supportsPersistence,
56 cloneInstance,
57 + cloneHiddenInstance,
58 + cloneUnhiddenInstance,
59 createContainerChildSet,
60 appendChildToContainerChildSet,
61 finalizeContainerChildren,
@@ -84,42 +88,48 @@ function markRef(workInProgress: Fiber) {
88 workInProgress.effectTag |= Ref;
89 }
90
87 -function appendAllChildren(parent: Instance, workInProgress: Fiber) {
88 - // We only have the top Fiber that was created but we need recurse down its
89 - // children to find all the terminal nodes.
90 - let node = workInProgress.child;
91 - while (node !== null) {
92 - if (node.tag === HostComponent || node.tag === HostText) {
93 - appendInitialChild(parent, node.stateNode);
94 - } else if (node.tag === HostPortal) {
95 - // If we have a portal child, then we don't want to traverse
96 - // down its children. Instead, we'll get insertions from each child in
97 - // the portal directly.
98 - } else if (node.child !== null) {
99 - node.child.return = node;
100 - node = node.child;
101 - continue;
102 - }
103 - if (node === workInProgress) {
104 - return;
105 - }
106 - while (node.sibling === null) {
107 - if (node.return === null || node.return === workInProgress) {
108 - return;
109 - }
110 - node = node.return;
111 - }
112 - node.sibling.return = node.return;
113 - node = node.sibling;
114 - }
115 -}
116 -
91 +let appendAllChildren;
92 let updateHostContainer;
93 let updateHostComponent;
94 let updateHostText;
95 if (supportsMutation) {
96 // Mutation mode
97
98 + appendAllChildren = function(
99 + parent: Instance,
100 + workInProgress: Fiber,
101 + needsVisibilityToggle: boolean,
102 + isHidden: boolean,
103 + ) {
104 + // We only have the top Fiber that was created but we need recurse down its
105 + // children to find all the terminal nodes.
106 + let node = workInProgress.child;
107 + while (node !== null) {
108 + if (node.tag === HostComponent || node.tag === HostText) {
109 + appendInitialChild(parent, node.stateNode);
110 + } else if (node.tag === HostPortal) {
111 + // If we have a portal child, then we don't want to traverse
112 + // down its children. Instead, we'll get insertions from each child in
113 + // the portal directly.
114 + } else if (node.child !== null) {
115 + node.child.return = node;
116 + node = node.child;
117 + continue;
118 + }
119 + if (node === workInProgress) {
120 + return;
121 + }
122 + while (node.sibling === null) {
123 + if (node.return === null || node.return === workInProgress) {
124 + return;
125 + }
126 + node = node.return;
127 + }
128 + node.sibling.return = node.return;
129 + node = node.sibling;
130 + }
131 + };
132 +
133 updateHostContainer = function(workInProgress: Fiber) {
134 // Noop
135 };
@@ -178,26 +188,202 @@ if (supportsMutation) {
188 } else if (supportsPersistence) {
189 // Persistent host tree mode
190
191 + appendAllChildren = function(
192 + parent: Instance,
193 + workInProgress: Fiber,
194 + needsVisibilityToggle: boolean,
195 + isHidden: boolean,
196 + ) {
197 + // We only have the top Fiber that was created but we need recurse down its
198 + // children to find all the terminal nodes.
199 + let node = workInProgress.child;
200 + while (node !== null) {
201 + // eslint-disable-next-line no-labels
202 + branches: if (node.tag === HostComponent) {
203 + let instance = node.stateNode;
204 + if (needsVisibilityToggle) {
205 + const props = node.memoizedProps;
206 + const type = node.type;
207 + if (isHidden) {
208 + // This child is inside a timed out tree. Hide it.
209 + instance = cloneHiddenInstance(instance, type, props, node);
210 + } else {
211 + // This child was previously inside a timed out tree. If it was not
212 + // updated during this render, it may need to be unhidden. Clone
213 + // again to be sure.
214 + instance = cloneUnhiddenInstance(instance, type, props, node);
215 + }
216 + node.stateNode = instance;
217 + }
218 + appendInitialChild(parent, instance);
219 + } else if (node.tag === HostText) {
220 + let instance = node.stateNode;
221 + if (needsVisibilityToggle) {
222 + const text = node.memoizedProps;
223 + const rootContainerInstance = getRootHostContainer();
224 + const currentHostContext = getHostContext();
225 + if (isHidden) {
226 + instance = createHiddenTextInstance(
227 + text,
228 + rootContainerInstance,
229 + currentHostContext,
230 + workInProgress,
231 + );
232 + } else {
233 + instance = createTextInstance(
234 + text,
235 + rootContainerInstance,
236 + currentHostContext,
237 + workInProgress,
238 + );
239 + }
240 + node.stateNode = instance;
241 + }
242 + appendInitialChild(parent, instance);
243 + } else if (node.tag === HostPortal) {
244 + // If we have a portal child, then we don't want to traverse
245 + // down its children. Instead, we'll get insertions from each child in
246 + // the portal directly.
247 + } else if (node.tag === SuspenseComponent) {
248 + const current = node.alternate;
249 + if (current !== null) {
250 + const oldState: SuspenseState = current.memoizedState;
251 + const newState: SuspenseState = node.memoizedState;
252 + const oldIsHidden = oldState !== null && oldState.didTimeout;
253 + const newIsHidden = newState !== null && newState.didTimeout;
254 + if (oldIsHidden !== newIsHidden) {
255 + // The placeholder either just timed out or switched back to the normal
256 + // children after having previously timed out. Toggle the visibility of
257 + // the direct host children.
258 + const primaryChildParent = newIsHidden ? node.child : node;
259 + if (primaryChildParent !== null) {
260 + appendAllChildren(parent, primaryChildParent, true, newIsHidden);
261 + }
262 + // eslint-disable-next-line no-labels
263 + break branches;
264 + }
265 + }
266 + if (node.child !== null) {
267 + // Continue traversing like normal
268 + node.child.return = node;
269 + node = node.child;
270 + continue;
271 + }
272 + } else if (node.child !== null) {
273 + node.child.return = node;
274 + node = node.child;
275 + continue;
276 + }
277 + // $FlowFixMe This is correct but Flow is confused by the labeled break.
278 + node = (node: Fiber);
279 + if (node === workInProgress) {
280 + return;
281 + }
282 + while (node.sibling === null) {
283 + if (node.return === null || node.return === workInProgress) {
284 + return;
285 + }
286 + node = node.return;
287 + }
288 + node.sibling.return = node.return;
289 + node = node.sibling;
290 + }
291 + };
292 +
293 // An unfortunate fork of appendAllChildren because we have two different parent types.
294 const appendAllChildrenToContainer = function(
295 containerChildSet: ChildSet,
296 workInProgress: Fiber,
297 + needsVisibilityToggle: boolean,
298 + isHidden: boolean,
299 ) {
300 // We only have the top Fiber that was created but we need recurse down its
301 // children to find all the terminal nodes.
302 let node = workInProgress.child;
303 while (node !== null) {
190 - if (node.tag === HostComponent || node.tag === HostText) {
191 - appendChildToContainerChildSet(containerChildSet, node.stateNode);
304 + // eslint-disable-next-line no-labels
305 + branches: if (node.tag === HostComponent) {
306 + let instance = node.stateNode;
307 + if (needsVisibilityToggle) {
308 + const props = node.memoizedProps;
309 + const type = node.type;
310 + if (isHidden) {
311 + // This child is inside a timed out tree. Hide it.
312 + instance = cloneHiddenInstance(instance, type, props, node);
313 + } else {
314 + // This child was previously inside a timed out tree. If it was not
315 + // updated during this render, it may need to be unhidden. Clone
316 + // again to be sure.
317 + instance = cloneUnhiddenInstance(instance, type, props, node);
318 + }
319 + node.stateNode = instance;
320 + }
321 + appendChildToContainerChildSet(containerChildSet, instance);
322 + } else if (node.tag === HostText) {
323 + let instance = node.stateNode;
324 + if (needsVisibilityToggle) {
325 + const text = node.memoizedProps;
326 + const rootContainerInstance = getRootHostContainer();
327 + const currentHostContext = getHostContext();
328 + if (isHidden) {
329 + instance = createHiddenTextInstance(
330 + text,
331 + rootContainerInstance,
332 + currentHostContext,
333 + workInProgress,
334 + );
335 + } else {
336 + instance = createTextInstance(
337 + text,
338 + rootContainerInstance,
339 + currentHostContext,
340 + workInProgress,
341 + );
342 + }
343 + node.stateNode = instance;
344 + }
345 + appendChildToContainerChildSet(containerChildSet, instance);
346 } else if (node.tag === HostPortal) {
347 // If we have a portal child, then we don't want to traverse
348 // down its children. Instead, we'll get insertions from each child in
349 // the portal directly.
350 + } else if (node.tag === SuspenseComponent) {
351 + const current = node.alternate;
352 + if (current !== null) {
353 + const oldState: SuspenseState = current.memoizedState;
354 + const newState: SuspenseState = node.memoizedState;
355 + const oldIsHidden = oldState !== null && oldState.didTimeout;
356 + const newIsHidden = newState !== null && newState.didTimeout;
357 + if (oldIsHidden !== newIsHidden) {
358 + // The placeholder either just timed out or switched back to the normal
359 + // children after having previously timed out. Toggle the visibility of
360 + // the direct host children.
361 + const primaryChildParent = newIsHidden ? node.child : node;
362 + if (primaryChildParent !== null) {
363 + appendAllChildrenToContainer(
364 + containerChildSet,
365 + primaryChildParent,
366 + true,
367 + newIsHidden,
368 + );
369 + }
370 + // eslint-disable-next-line no-labels
371 + break branches;
372 + }
373 + }
374 + if (node.child !== null) {
375 + // Continue traversing like normal
376 + node.child.return = node;
377 + node = node.child;
378 + continue;
379 + }
380 } else if (node.child !== null) {
381 node.child.return = node;
382 node = node.child;
383 continue;
384 }
385 + // $FlowFixMe This is correct but Flow is confused by the labeled break.
386 + node = (node: Fiber);
387 if (node === workInProgress) {
388 return;
389 }
@@ -224,7 +410,7 @@ if (supportsMutation) {
410 const container = portalOrRoot.containerInfo;
411 let newChildSet = createContainerChildSet(container);
412 // If children might have changed, we have to add them all to the set.
227 - appendAllChildrenToContainer(newChildSet, workInProgress);
413 + appendAllChildrenToContainer(newChildSet, workInProgress, false, false);
414 portalOrRoot.pendingChildren = newChildSet;
415 // Schedule an update on the container to swap out the container.
416 markUpdate(workInProgress);
@@ -297,7 +483,7 @@ if (supportsMutation) {
483 markUpdate(workInProgress);
484 } else {
485 // If children might have changed, we have to add them all to the set.
300 - appendAllChildren(newInstance, workInProgress);
486 + appendAllChildren(newInstance, workInProgress, false, false);
487 }
488 };
489 updateHostText = function(
@@ -353,6 +539,8 @@ function completeWork(
539 const newProps = workInProgress.pendingProps;
540
541 switch (workInProgress.tag) {
542 + case IndeterminateComponent:
543 + break;
544 case FunctionComponent:
545 case FunctionComponentLazy:
546 break;
@@ -445,7 +633,7 @@ function completeWork(
633 workInProgress,
634 );
635
448 - appendAllChildren(instance, workInProgress);
636 + appendAllChildren(instance, workInProgress, false, false);
637
638 // Certain renderers require commit-time effects for initial mount.
639 // (eg DOM renderer supports auto-focus for certain elements).
@@ -508,8 +696,18 @@ function completeWork(
696 case ForwardRef:
697 case ForwardRefLazy:
698 break;
511 - case SuspenseComponent:
699 + case SuspenseComponent: {
700 + const nextState = workInProgress.memoizedState;
701 + const prevState = current !== null ? current.memoizedState : null;
702 + const nextDidTimeout = nextState !== null && nextState.didTimeout;
703 + const prevDidTimeout = prevState !== null && prevState.didTimeout;
704 + if (nextDidTimeout !== prevDidTimeout) {
705 + // If this render commits, and it switches between the normal state
706 + // and the timed-out state, schedule an effect.
707 + workInProgress.effectTag |= Update;
708 + }
709 break;
710 + }
711 case Fragment:
712 break;
713 case Mode:
@@ -529,15 +727,6 @@ function completeWork(
727 case PureComponent:
728 case PureComponentLazy:
729 break;
532 - // Error cases
533 - case IndeterminateComponent:
534 - invariant(
535 - false,
536 - 'An indeterminate component should have become determinate before ' +
537 - 'completing. This error is likely caused by a bug in React. Please ' +
538 - 'file an issue.',
539 - );
540 - // eslint-disable-next-line no-fallthrough
730 default:
731 invariant(
732 false,
packages/react-reconciler/src/ReactFiberScheduler.js
+19 -6
@@ -110,7 +110,12 @@ import {
110 computeAsyncExpiration,
111 computeInteractiveExpiration,
112 } from './ReactFiberExpirationTime';
113 -import {ConcurrentMode, ProfileMode, NoContext} from './ReactTypeOfMode';
113 +import {
114 + ConcurrentMode,
115 + ProfileMode,
116 + NoContext,
117 + StrictMode,
118 +} from './ReactTypeOfMode';
119 import {enqueueUpdate, resetCurrentlyProcessingQueue} from './ReactUpdateQueue';
120 import {createCapturedValue} from './ReactCapturedValue';
121 import {
@@ -1563,7 +1568,8 @@ function renderDidError() {
1568
1569 function retrySuspendedRoot(
1570 root: FiberRoot,
1566 - fiber: Fiber,
1571 + boundaryFiber: Fiber,
1572 + sourceFiber: Fiber,
1573 suspendedTime: ExpirationTime,
1574 ) {
1575 let retryTime;
@@ -1576,18 +1582,18 @@ function retrySuspendedRoot(
1582 } else {
1583 // Suspense already timed out. Compute a new expiration time
1584 const currentTime = requestCurrentTime();
1579 - retryTime = computeExpirationForFiber(currentTime, fiber);
1585 + retryTime = computeExpirationForFiber(currentTime, boundaryFiber);
1586 markPendingPriorityLevel(root, retryTime);
1587 }
1588
1583 - // TODO: If the placeholder fiber has already rendered the primary children
1589 + // TODO: If the suspense fiber has already rendered the primary children
1590 // without suspending (that is, all of the promises have already resolved),
1591 // we should not trigger another update here. One case this happens is when
1592 // we are in sync mode and a single promise is thrown both on initial render
1593 // and on update; we attach two .then(retrySuspendedRoot) callbacks and each
1594 // one performs Sync work, rerendering the Suspense.
1595
1590 - if ((fiber.mode & ConcurrentMode) !== NoContext) {
1596 + if ((boundaryFiber.mode & ConcurrentMode) !== NoContext) {
1597 if (root === nextRoot && nextRenderExpirationTime === suspendedTime) {
1598 // Received a ping at the same priority level at which we're currently
1599 // rendering. Restart from the root.
@@ -1595,7 +1601,14 @@ function retrySuspendedRoot(
1601 }
1602 }
1603
1598 - scheduleWorkToRoot(fiber, retryTime);
1604 + scheduleWorkToRoot(boundaryFiber, retryTime);
1605 + if ((boundaryFiber.mode & StrictMode) === NoContext) {
1606 + // Outside of strict mode, we must schedule an update on the source fiber,
1607 + // too, since it already committed in an inconsistent state and therefore
1608 + // does not have any pending work.
1609 + scheduleWorkToRoot(sourceFiber, retryTime);
1610 + }
1611 +
1612 const rootExpirationTime = root.expirationTime;
1613 if (rootExpirationTime !== NoWork) {
1614 requestWork(root, rootExpirationTime);
packages/react-reconciler/src/ReactFiberSuspenseComponent.js new
+25
@@ -0,0 +1,25 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 + * @flow
8 + */
9 +
10 +import type {ExpirationTime} from './ReactFiberExpirationTime';
11 +
12 +export type SuspenseState = {|
13 + // Whether a component in the child subtree already suspended. If true,
14 + // subsequent suspends should bubble up to the next boundary.
15 + alreadyCaptured: boolean,
16 + // Whether the boundary renders the primary or fallback children. This is
17 + // separate from `alreadyCaptured` because outside of strict mode, when a
18 + // boundary times out, the first commit renders the primary children in an
19 + // incomplete state, then performs a second commit to switch the fallback.
20 + // In that first commit, `alreadyCaptured` is false and `didTimeout` is true.
21 + didTimeout: boolean,
22 + // The time at which the boundary timed out. This is separate from
23 + // `didTimeout` because it's not set unless the boundary actually commits.
24 + timedOutAt: ExpirationTime,
25 +|};
packages/react-reconciler/src/ReactFiberUnwindWork.js
+42 -25
@@ -18,7 +18,6 @@ import {unstable_wrap as Schedule_tracing_wrap} from 'scheduler/tracing';
18 import getComponentName from 'shared/getComponentName';
19 import warningWithoutStack from 'shared/warningWithoutStack';
20 import {
21 - IndeterminateComponent,
21 FunctionComponent,
22 ClassComponent,
23 ClassComponentLazy,
@@ -33,7 +32,7 @@ import {
32 Incomplete,
33 NoEffect,
34 ShouldCapture,
36 - Update as UpdateEffect,
35 + Callback as CallbackEffect,
36 LifecycleEffectMask,
37 } from 'shared/ReactSideEffectTags';
38 import {enableSchedulerTracing} from 'shared/ReactFeatureFlags';
@@ -61,7 +60,7 @@ import {
60 isAlreadyFailedLegacyErrorBoundary,
61 retrySuspendedRoot,
62 } from './ReactFiberScheduler';
64 -import {Sync} from './ReactFiberExpirationTime';
63 +import {NoWork, Sync} from './ReactFiberExpirationTime';
64
65 import invariant from 'shared/invariant';
66 import maxSigned31BitInt from './maxSigned31BitInt';
@@ -176,21 +175,16 @@ function throwException(
175 do {
176 if (workInProgress.tag === SuspenseComponent) {
177 const current = workInProgress.alternate;
179 - if (
180 - current !== null &&
181 - current.memoizedState === true &&
182 - current.stateNode !== null
183 - ) {
184 - // Reached a placeholder that already timed out. Each timed out
185 - // placeholder acts as the root of a new suspense boundary.
186 -
187 - // Use the time at which the placeholder timed out as the start time
188 - // for the current render.
189 - const timedOutAt = current.stateNode.timedOutAt;
190 - startTimeMs = expirationTimeToMs(timedOutAt);
191 -
192 - // Do not search any further.
193 - break;
178 + if (current !== null) {
179 + const currentState = current.memoizedState;
180 + if (currentState !== null && currentState.didTimeout) {
181 + // Reached a boundary that already timed out. Do not search
182 + // any further.
183 + const timedOutAt = currentState.timedOutAt;
184 + startTimeMs = expirationTimeToMs(timedOutAt);
185 + // Do not search any further.
186 + break;
187 + }
188 }
189 let timeoutPropMs = workInProgress.pendingProps.maxDuration;
190 if (typeof timeoutPropMs === 'number') {
@@ -211,7 +205,8 @@ function throwException(
205 workInProgress = returnFiber;
206 do {
207 if (workInProgress.tag === SuspenseComponent) {
214 - const didTimeout = workInProgress.memoizedState;
208 + const state = workInProgress.memoizedState;
209 + const didTimeout = state !== null && state.didTimeout;
210 if (!didTimeout) {
211 // Found the nearest boundary.
212
@@ -227,6 +222,7 @@ function throwException(
222 null,
223 root,
224 workInProgress,
225 + sourceFiber,
226 pingTime,
227 );
228 if (enableSchedulerTracing) {
@@ -243,7 +239,7 @@ function throwException(
239 // inside a strict mode tree. If the Suspense is outside of it, we
240 // should *not* suspend the commit.
241 if ((workInProgress.mode & StrictMode) === NoEffect) {
246 - workInProgress.effectTag |= UpdateEffect;
242 + workInProgress.effectTag |= CallbackEffect;
243
244 // Unmount the source fiber's children
245 const nextChildren = null;
@@ -254,11 +250,6 @@ function throwException(
250 renderExpirationTime,
251 );
252 sourceFiber.effectTag &= ~Incomplete;
257 - if (sourceFiber.tag === IndeterminateComponent) {
258 - // Let's just assume it's a function component. This fiber will
259 - // be unmounted in the immediate next commit, anyway.
260 - sourceFiber.tag = FunctionComponent;
261 - }
253
254 if (
255 sourceFiber.tag === ClassComponent ||
@@ -437,6 +428,32 @@ function unwindWork(
428 const effectTag = workInProgress.effectTag;
429 if (effectTag & ShouldCapture) {
430 workInProgress.effectTag = (effectTag & ~ShouldCapture) | DidCapture;
431 + // Captured a suspense effect. Set the boundary's `alreadyCaptured`
432 + // state to true so we know to render the fallback.
433 + const current = workInProgress.alternate;
434 + const currentState = current !== null ? current.memoizedState : null;
435 + let nextState = workInProgress.memoizedState;
436 + if (currentState === null) {
437 + // No existing state. Create a new object.
438 + nextState = {
439 + alreadyCaptured: true,
440 + didTimeout: false,
441 + timedOutAt: NoWork,
442 + };
443 + } else if (currentState === nextState) {
444 + // There is an existing state but it's the same as the current tree's.
445 + // Clone the object.
446 + nextState = {
447 + alreadyCaptured: true,
448 + didTimeout: currentState.didTimeout,
449 + timedOutAt: currentState.timedOutAt,
450 + };
451 + } else {
452 + // Already have a clone, so it's safe to mutate.
453 + nextState.alreadyCaptured = true;
454 + }
455 + workInProgress.memoizedState = nextState;
456 + // Re-render the boundary.
457 return workInProgress;
458 }
459 return null;
packages/react-reconciler/src/__tests__/ReactExpiration-test.internal.js
+1 -1
@@ -23,7 +23,7 @@ describe('ReactExpiration', () => {
23 });
24
25 function span(prop) {
26 - return {type: 'span', children: [], prop};
26 + return {type: 'span', children: [], prop, hidden: false};
27 }
28
29 it('increases priority of updates as time progresses', () => {
packages/react-reconciler/src/__tests__/ReactFragment-test.js
+9 -7
@@ -20,17 +20,19 @@ describe('ReactFragment', () => {
20 ReactNoop = require('react-noop-renderer');
21 });
22
23 - function span(prop) {
24 - return {type: 'span', children: [], prop};
23 + function div(...children) {
24 + children = children.map(
25 + c => (typeof c === 'string' ? {text: c, hidden: false} : c),
26 + );
27 + return {type: 'div', children, prop: undefined, hidden: false};
28 }
29
27 - function text(val) {
28 - return {text: val};
30 + function span(prop) {
31 + return {type: 'span', children: [], prop, hidden: false};
32 }
33
31 - function div(...children) {
32 - children = children.map(c => (typeof c === 'string' ? {text: c} : c));
33 - return {type: 'div', children, prop: undefined};
34 + function text(t) {
35 + return {text: t, hidden: false};
36 }
37
38 it('should render a single child via noop renderer', () => {
packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js
+2 -2
@@ -28,11 +28,11 @@ describe('ReactIncrementalErrorHandling', () => {
28
29 function div(...children) {
30 children = children.map(c => (typeof c === 'string' ? {text: c} : c));
31 - return {type: 'div', children, prop: undefined};
31 + return {type: 'div', children, prop: undefined, hidden: false};
32 }
33
34 function span(prop) {
35 - return {type: 'span', children: [], prop};
35 + return {type: 'span', children: [], prop, hidden: false};
36 }
37
38 function normalizeCodeLocInfo(str) {
packages/react-reconciler/src/__tests__/ReactIncrementalScheduling-test.internal.js
+1 -1
@@ -24,7 +24,7 @@ describe('ReactIncrementalScheduling', () => {
24 });
25
26 function span(prop) {
27 - return {type: 'span', children: [], prop};
27 + return {type: 'span', children: [], prop, hidden: false};
28 }
29
30 it('schedules and flushes deferred work', () => {
packages/react-reconciler/src/__tests__/ReactIncrementalSideEffects-test.internal.js
+124 -65
@@ -24,16 +24,18 @@ describe('ReactIncrementalSideEffects', () => {
24 });
25
26 function div(...children) {
27 - children = children.map(c => (typeof c === 'string' ? text(c) : c));
28 - return {type: 'div', children, prop: undefined};
27 + children = children.map(
28 + c => (typeof c === 'string' ? {text: c, hidden: false} : c),
29 + );
30 + return {type: 'div', children, prop: undefined, hidden: false};
31 }
32
33 function span(prop) {
32 - return {type: 'span', children: [], prop};
34 + return {type: 'span', children: [], prop, hidden: false};
35 }
36
37 function text(t) {
36 - return {text: t};
38 + return {text: t, hidden: false};
39 }
40
41 it('can update child nodes of a host instance', () => {
@@ -417,16 +419,33 @@ describe('ReactIncrementalSideEffects', () => {
419 ReactNoop.render(<Foo text="foo" />);
420 ReactNoop.flush();
421
420 - expect(ReactNoop.getChildren()).toEqual([div(div(span('foo')))]);
422 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
423 + <div>
424 + <div hidden={true}>
425 + <span prop="foo" />
426 + </div>
427 + </div>,
428 + );
429
430 ReactNoop.render(<Foo text="bar" />);
431 ReactNoop.flushDeferredPri(20);
432
425 - expect(ReactNoop.getChildren()).toEqual([div(div(span('foo')))]);
426 -
433 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
434 + <div>
435 + <div hidden={true}>
436 + <span prop="foo" />
437 + </div>
438 + </div>,
439 + );
440 ReactNoop.flush();
441
429 - expect(ReactNoop.getChildren()).toEqual([div(div(span('bar')))]);
442 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
443 + <div>
444 + <div hidden={true}>
445 + <span prop="bar" />
446 + </div>
447 + </div>,
448 + );
449 });
450
451 it('can reuse side-effects after being preempted', () => {
@@ -460,9 +479,14 @@ describe('ReactIncrementalSideEffects', () => {
479 ReactNoop.render(<Foo text="foo" step={0} />);
480 ReactNoop.flush();
481
463 - expect(ReactNoop.getChildren()).toEqual([
464 - div(div(span('Hi'), span('foo'))),
465 - ]);
482 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
483 + <div hidden={true}>
484 + <div>
485 + <span prop="Hi" />
486 + <span prop="foo" />
487 + </div>
488 + </div>,
489 + );
490
491 // Make a quick update which will schedule low priority work to
492 // update the middle content.
@@ -470,9 +494,14 @@ describe('ReactIncrementalSideEffects', () => {
494 ReactNoop.flushDeferredPri(30);
495
496 // The tree remains unchanged.
473 - expect(ReactNoop.getChildren()).toEqual([
474 - div(div(span('Hi'), span('foo'))),
475 - ]);
497 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
498 + <div hidden={true}>
499 + <div>
500 + <span prop="Hi" />
501 + <span prop="foo" />
502 + </div>
503 + </div>,
504 + );
505
506 // The first Bar has already completed its update but we'll interrupt it to
507 // render some higher priority work. The middle content will bailout so
@@ -484,9 +513,14 @@ describe('ReactIncrementalSideEffects', () => {
513 // we should be able to reuse the reconciliation work that we already did
514 // without restarting. The side-effects should still be replayed.
515
487 - expect(ReactNoop.getChildren()).toEqual([
488 - div(div(span('Hello'), span('World'))),
489 - ]);
516 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
517 + <div hidden={true}>
518 + <div>
519 + <span prop="Hello" />
520 + <span prop="World" />
521 + </div>
522 + </div>,
523 + );
524 });
525
526 it('can reuse side-effects after being preempted, if shouldComponentUpdate is false', () => {
@@ -525,9 +559,14 @@ describe('ReactIncrementalSideEffects', () => {
559 ReactNoop.render(<Foo text="foo" step={0} />);
560 ReactNoop.flush();
561
528 - expect(ReactNoop.getChildren()).toEqual([
529 - div(div(span('Hi'), span('foo'))),
530 - ]);
562 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
563 + <div hidden={true}>
564 + <div>
565 + <span prop="Hi" />
566 + <span prop="foo" />
567 + </div>
568 + </div>,
569 + );
570
571 // Make a quick update which will schedule low priority work to
572 // update the middle content.
@@ -535,9 +574,14 @@ describe('ReactIncrementalSideEffects', () => {
574 ReactNoop.flushDeferredPri(35);
575
576 // The tree remains unchanged.
538 - expect(ReactNoop.getChildren()).toEqual([
539 - div(div(span('Hi'), span('foo'))),
540 - ]);
577 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
578 + <div hidden={true}>
579 + <div>
580 + <span prop="Hi" />
581 + <span prop="foo" />
582 + </div>
583 + </div>,
584 + );
585
586 // The first Bar has already completed its update but we'll interrupt it to
587 // render some higher priority work. The middle content will bailout so
@@ -549,9 +593,14 @@ describe('ReactIncrementalSideEffects', () => {
593 // we should be able to reuse the reconciliation work that we already did
594 // without restarting. The side-effects should still be replayed.
595
552 - expect(ReactNoop.getChildren()).toEqual([
553 - div(div(span('Hello'), span('World'))),
554 - ]);
596 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
597 + <div hidden={true}>
598 + <div>
599 + <span prop="Hello" />
600 + <span prop="World" />
601 + </div>
602 + </div>,
603 + );
604 });
605
606 it('can update a completed tree before it has a chance to commit', () => {
@@ -597,7 +646,11 @@ describe('ReactIncrementalSideEffects', () => {
646
647 ReactNoop.render(<Foo />);
648 ReactNoop.flush();
600 - expect(ReactNoop.getChildren()).toEqual([div(span(1))]);
649 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
650 + <div hidden={true}>
651 + <span prop={1} />
652 + </div>,
653 + );
654 });
655
656 xit('can defer side-effects and resume them later on', () => {
@@ -855,9 +908,16 @@ describe('ReactIncrementalSideEffects', () => {
908 }
909 ReactNoop.render(<Foo tick={0} idx={0} />);
910 ReactNoop.flush();
858 - expect(ReactNoop.getChildren()).toEqual([
859 - div(span(0), div(span(0), span(0), span(0))),
860 - ]);
911 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
912 + <div>
913 + <span prop={0} />
914 + <div hidden={true}>
915 + <span prop={0} />
916 + <span prop={0} />
917 + <span prop={0} />
918 + </div>
919 + </div>,
920 + );
921
922 expect(ops).toEqual(['Foo', 'Bar', 'Bar', 'Bar']);
923
@@ -865,18 +925,17 @@ describe('ReactIncrementalSideEffects', () => {
925
926 ReactNoop.render(<Foo tick={1} idx={1} />);
927 ReactNoop.flushDeferredPri(70 + 5);
868 - expect(ReactNoop.getChildren()).toEqual([
869 - div(
870 - // Updated.
871 - span(1),
872 - div(
873 - // Still not updated.
874 - span(0),
875 - span(0),
876 - span(0),
877 - ),
878 - ),
879 - ]);
928 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
929 + <div>
930 + {/* Updated */}
931 + <span prop={1} />
932 + <div hidden={true}>
933 + <span prop={0} />
934 + <span prop={0} />
935 + <span prop={0} />
936 + </div>
937 + </div>,
938 + );
939
940 expect(ops).toEqual(['Foo', 'Bar', 'Bar']);
941 ops = [];
@@ -888,18 +947,18 @@ describe('ReactIncrementalSideEffects', () => {
947 // TODO: The cycles it takes to do this could be lowered with further
948 // optimizations.
949 ReactNoop.flushDeferredPri(35);
891 - expect(ReactNoop.getChildren()).toEqual([
892 - div(
893 - // Updated.
894 - span(1),
895 - div(
896 - // Still not updated.
897 - span(0),
898 - span(0),
899 - span(0),
900 - ),
901 - ),
902 - ]);
950 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
951 + <div>
952 + {/* Updated */}
953 + <span prop={1} />
954 + <div hidden={true}>
955 + {/* Still not updated */}
956 + <span prop={0} />
957 + <span prop={0} />
958 + <span prop={0} />
959 + </div>
960 + </div>,
961 + );
962
963 expect(ops).toEqual(['Bar']);
964 ops = [];
@@ -907,17 +966,17 @@ describe('ReactIncrementalSideEffects', () => {
966 // However, once we render fully, we will have enough time to finish it all
967 // at once.
968 ReactNoop.flush();
910 - expect(ReactNoop.getChildren()).toEqual([
911 - div(
912 - span(1),
913 - div(
914 - // Now we had enough time to finish the spans.
915 - span('X'),
916 - span(1),
917 - span(1),
918 - ),
919 - ),
920 - ]);
969 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
970 + <div>
971 + <span prop={1} />
972 + <div hidden={true}>
973 + {/* Now we had enough time to finish the spans. */}
974 + <span prop="X" />
975 + <span prop={1} />
976 + <span prop={1} />
977 + </div>
978 + </div>,
979 + );
980
981 expect(ops).toEqual(['Bar', 'Bar', 'Bar']);
982 });
packages/react-reconciler/src/__tests__/ReactIncrementalTriangle-test.internal.js
+1 -1
@@ -24,7 +24,7 @@ describe('ReactIncrementalTriangle', () => {
24 });
25
26 function span(prop) {
27 - return {type: 'span', children: [], prop};
27 + return {type: 'span', children: [], prop, hidden: false};
28 }
29
30 const FLUSH = 'FLUSH';
packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.internal.js
+1 -1
@@ -24,7 +24,7 @@ describe('ReactIncrementalUpdates', () => {
24 });
25
26 function span(prop) {
27 - return {type: 'span', children: [], prop};
27 + return {type: 'span', children: [], prop, hidden: false};
28 }
29
30 it('applies updates in order of priority', () => {
packages/react-reconciler/src/__tests__/ReactNewContext-test.internal.js
+1 -6
@@ -25,18 +25,13 @@ describe('ReactNewContext', () => {
25 gen = require('random-seed');
26 });
27
28 - // function div(...children) {
29 - // children = children.map(c => (typeof c === 'string' ? {text: c} : c));
30 - // return {type: 'div', children, prop: undefined};
31 - // }
32 -
28 function Text(props) {
29 ReactNoop.yield(props.text);
30 return <span prop={props.text} />;
31 }
32
33 function span(prop) {
39 - return {type: 'span', children: [], prop};
34 + return {type: 'span', children: [], prop, hidden: false};
35 }
36
37 function readContext(Context, observedBits) {
packages/react-reconciler/src/__tests__/ReactPersistent-test.js
+5 -3
@@ -37,12 +37,14 @@ describe('ReactPersistent', () => {
37 }
38
39 function div(...children) {
40 - children = children.map(c => (typeof c === 'string' ? {text: c} : c));
41 - return {type: 'div', children, prop: undefined};
40 + children = children.map(
41 + c => (typeof c === 'string' ? {text: c, hidden: false} : c),
42 + );
43 + return {type: 'div', children, prop: undefined, hidden: false};
44 }
45
46 function span(prop) {
45 - return {type: 'span', children: [], prop};
47 + return {type: 'span', children: [], prop, hidden: false};
48 }
49
50 function getChildren() {
packages/react-reconciler/src/__tests__/ReactPure-test.internal.js
+1 -1
@@ -26,7 +26,7 @@ describe('pure', () => {
26 });
27
28 function span(prop) {
29 - return {type: 'span', children: [], prop};
29 + return {type: 'span', children: [], prop, hidden: false};
30 }
31
32 function Text(props) {
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+30
@@ -241,4 +241,34 @@ describe('ReactSuspense', () => {
241 ]);
242 expect(root).toMatchRenderedOutput('AsyncSibling');
243 });
244 +
245 + it('mounts a lazy class component in non-concurrent mode', async () => {
246 + class Class extends React.Component {
247 + componentDidMount() {
248 + ReactTestRenderer.unstable_yield('Did mount: ' + this.props.label);
249 + }
250 + componentDidUpdate() {
251 + ReactTestRenderer.unstable_yield('Did update: ' + this.props.label);
252 + }
253 + render() {
254 + return <Text text={this.props.label} />;
255 + }
256 + }
257 +
258 + const LazyClass = React.lazy(() => Promise.resolve(Class));
259 +
260 + const root = ReactTestRenderer.create(
261 + <Suspense fallback={<Text text="Loading..." />}>
262 + <LazyClass label="Hi" />
263 + </Suspense>,
264 + );
265 +
266 + expect(ReactTestRenderer).toHaveYielded(['Loading...']);
267 + expect(root).toMatchRenderedOutput('Loading...');
268 +
269 + await LazyClass;
270 +
271 + expect(ReactTestRenderer).toHaveYielded(['Hi', 'Did mount: Hi']);
272 + expect(root).toMatchRenderedOutput('Hi');
273 + });
274 });
packages/react-reconciler/src/__tests__/ReactSuspensePlaceholder-test.internal.js new
+226
@@ -0,0 +1,226 @@
1 +/**
2 + * Copyright (c) 2013-present, Facebook, Inc.
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 +runPlaceholderTests('ReactSuspensePlaceholder (mutation)', () =>
12 + require('react-noop-renderer'),
13 +);
14 +runPlaceholderTests('ReactSuspensePlaceholder (persistence)', () =>
15 + require('react-noop-renderer/persistent'),
16 +);
17 +
18 +function runPlaceholderTests(suiteLabel, loadReactNoop) {
19 + let React;
20 + let ReactTestRenderer;
21 + let ReactFeatureFlags;
22 + let ReactCache;
23 + let Suspense;
24 +
25 + let cache;
26 + let TextResource;
27 + let textResourceShouldFail;
28 +
29 + describe(suiteLabel, () => {
30 + beforeEach(() => {
31 + jest.resetModules();
32 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
33 + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
34 + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
35 + React = require('react');
36 + ReactTestRenderer = require('react-test-renderer');
37 + // JestReact = require('jest-react');
38 + ReactCache = require('react-cache');
39 +
40 + Suspense = React.unstable_Suspense;
41 +
42 + function invalidateCache() {
43 + cache = ReactCache.createCache(invalidateCache);
44 + }
45 + invalidateCache();
46 + TextResource = ReactCache.createResource(([text, ms = 0]) => {
47 + let listeners = null;
48 + let status = 'pending';
49 + let value = null;
50 + return {
51 + then(resolve, reject) {
52 + switch (status) {
53 + case 'pending': {
54 + if (listeners === null) {
55 + listeners = [{resolve, reject}];
56 + setTimeout(() => {
57 + if (textResourceShouldFail) {
58 + ReactTestRenderer.unstable_yield(
59 + `Promise rejected [${text}]`,
60 + );
61 + status = 'rejected';
62 + value = new Error('Failed to load: ' + text);
63 + listeners.forEach(listener => listener.reject(value));
64 + } else {
65 + ReactTestRenderer.unstable_yield(
66 + `Promise resolved [${text}]`,
67 + );
68 + status = 'resolved';
69 + value = text;
70 + listeners.forEach(listener => listener.resolve(value));
71 + }
72 + }, ms);
73 + } else {
74 + listeners.push({resolve, reject});
75 + }
76 + break;
77 + }
78 + case 'resolved': {
79 + resolve(value);
80 + break;
81 + }
82 + case 'rejected': {
83 + reject(value);
84 + break;
85 + }
86 + }
87 + },
88 + };
89 + }, ([text, ms]) => text);
90 + textResourceShouldFail = false;
91 + });
92 +
93 + function Text(props) {
94 + ReactTestRenderer.unstable_yield(props.text);
95 + return props.text;
96 + }
97 +
98 + function AsyncText(props) {
99 + const text = props.text;
100 + try {
101 + TextResource.read(cache, [props.text, props.ms]);
102 + ReactTestRenderer.unstable_yield(text);
103 + return text;
104 + } catch (promise) {
105 + if (typeof promise.then === 'function') {
106 + ReactTestRenderer.unstable_yield(`Suspend! [${text}]`);
107 + } else {
108 + ReactTestRenderer.unstable_yield(`Error! [${text}]`);
109 + }
110 + throw promise;
111 + }
112 + }
113 +
114 + it('times out children that are already hidden', () => {
115 + class HiddenText extends React.PureComponent {
116 + render() {
117 + const text = this.props.text;
118 + ReactTestRenderer.unstable_yield(text);
119 + return <span hidden={true}>{text}</span>;
120 + }
121 + }
122 +
123 + function App(props) {
124 + return (
125 + <Suspense maxDuration={500} fallback={<Text text="Loading..." />}>
126 + <HiddenText text="A" />
127 + <span>
128 + <AsyncText ms={1000} text={props.middleText} />
129 + </span>
130 + <span>
131 + <Text text="C" />
132 + </span>
133 + </Suspense>
134 + );
135 + }
136 +
137 + // Initial mount
138 + const root = ReactTestRenderer.create(<App middleText="B" />, {
139 + unstable_isConcurrent: true,
140 + });
141 +
142 + expect(root).toFlushAndYield(['A', 'Suspend! [B]', 'C', 'Loading...']);
143 + expect(root).toMatchRenderedOutput(null);
144 +
145 + jest.advanceTimersByTime(1000);
146 + expect(ReactTestRenderer).toHaveYielded(['Promise resolved [B]']);
147 +
148 + expect(root).toFlushAndYield(['A', 'B', 'C']);
149 +
150 + expect(root).toMatchRenderedOutput(
151 + <React.Fragment>
152 + <span hidden={true}>A</span>
153 + <span>B</span>
154 + <span>C</span>
155 + </React.Fragment>,
156 + );
157 +
158 + // Update
159 + root.update(<App middleText="B2" />);
160 + expect(root).toFlushAndYield(['Suspend! [B2]', 'C', 'Loading...']);
161 +
162 + // Time out the update
163 + jest.advanceTimersByTime(750);
164 + expect(root).toFlushAndYield([]);
165 + expect(root).toMatchRenderedOutput('Loading...');
166 +
167 + // Resolve the promise
168 + jest.advanceTimersByTime(1000);
169 + expect(ReactTestRenderer).toHaveYielded(['Promise resolved [B2]']);
170 + expect(root).toFlushAndYield(['B2', 'C']);
171 +
172 + // Render the final update. A should still be hidden, because it was
173 + // given a `hidden` prop.
174 + expect(root).toMatchRenderedOutput(
175 + <React.Fragment>
176 + <span hidden={true}>A</span>
177 + <span>B2</span>
178 + <span>C</span>
179 + </React.Fragment>,
180 + );
181 + });
182 +
183 + it('times out text nodes', async () => {
184 + function App(props) {
185 + return (
186 + <Suspense maxDuration={500} fallback={<Text text="Loading..." />}>
187 + <Text text="A" />
188 + <AsyncText ms={1000} text={props.middleText} />
189 + <Text text="C" />
190 + </Suspense>
191 + );
192 + }
193 +
194 + // Initial mount
195 + const root = ReactTestRenderer.create(<App middleText="B" />, {
196 + unstable_isConcurrent: true,
197 + });
198 +
199 + expect(root).toFlushAndYield(['A', 'Suspend! [B]', 'C', 'Loading...']);
200 +
201 + expect(root).toMatchRenderedOutput(null);
202 +
203 + jest.advanceTimersByTime(1000);
204 + expect(ReactTestRenderer).toHaveYielded(['Promise resolved [B]']);
205 + expect(root).toFlushAndYield(['A', 'B', 'C']);
206 + expect(root).toMatchRenderedOutput('ABC');
207 +
208 + // Update
209 + root.update(<App middleText="B2" />);
210 + expect(root).toFlushAndYield(['A', 'Suspend! [B2]', 'C', 'Loading...']);
211 + // Time out the update
212 + jest.advanceTimersByTime(750);
213 + expect(root).toFlushAndYield([]);
214 + expect(root).toMatchRenderedOutput('Loading...');
215 +
216 + // Resolve the promise
217 + jest.advanceTimersByTime(1000);
218 + expect(ReactTestRenderer).toHaveYielded(['Promise resolved [B2]']);
219 + expect(root).toFlushAndYield(['A', 'B2', 'C']);
220 +
221 + // Render the final update. A should still be hidden, because it was
222 + // given a `hidden` prop.
223 + expect(root).toMatchRenderedOutput('AB2C');
224 + });
225 + });
226 +}
packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js
+113 -79
@@ -51,12 +51,14 @@ describe('ReactSuspenseWithNoopRenderer', () => {
51 });
52
53 function div(...children) {
54 - children = children.map(c => (typeof c === 'string' ? {text: c} : c));
55 - return {type: 'div', children, prop: undefined};
54 + children = children.map(
55 + c => (typeof c === 'string' ? {text: c, hidden: false} : c),
56 + );
57 + return {type: 'div', children, prop: undefined, hidden: false};
58 }
59
60 function span(prop) {
59 - return {type: 'span', children: [], prop};
61 + return {type: 'span', children: [], prop, hidden: false};
62 }
63
64 function advanceTimers(ms) {
@@ -788,18 +790,27 @@ describe('ReactSuspenseWithNoopRenderer', () => {
790
791 ReactNoop.render(<App />);
792 expect(ReactNoop.flush()).toEqual(['Suspend! [Async]', 'Loading...']);
791 - expect(ReactNoop.getChildren()).toEqual([]);
793 + expect(ReactNoop.getChildrenAsJSX()).toEqual(null);
794
795 ReactNoop.expire(2000);
796 await advanceTimers(2000);
797 expect(ReactNoop.flush()).toEqual([]);
796 - expect(ReactNoop.getChildren()).toEqual([div(), span('Loading...')]);
798 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
799 + <React.Fragment>
800 + <div hidden={true} />
801 + <span prop="Loading..." />
802 + </React.Fragment>,
803 + );
804
805 ReactNoop.expire(1000);
806 await advanceTimers(1000);
807
808 expect(ReactNoop.flush()).toEqual(['Promise resolved [Async]', 'Async']);
802 - expect(ReactNoop.getChildren()).toEqual([div(span('Async'))]);
809 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
810 + <div>
811 + <span prop="Async" />
812 + </div>,
813 + );
814 });
815
816 it('flushes all expired updates in a single batch', async () => {
@@ -966,12 +977,13 @@ describe('ReactSuspenseWithNoopRenderer', () => {
977 'Loading (3)',
978 'Promise resolved [Step: 1]',
979 'Step: 1',
969 - 'Sibling',
970 - ]);
971 - expect(ReactNoop.getChildren()).toEqual([
972 - span('Step: 1'),
973 - span('Sibling'),
980 ]);
981 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
982 + <React.Fragment>
983 + <span prop="Step: 1" />
984 + <span prop="Sibling" />
985 + </React.Fragment>,
986 + );
987
988 // Update. This starts out asynchronously.
989 text.current.setState({step: 2}, () =>
@@ -987,24 +999,26 @@ describe('ReactSuspenseWithNoopRenderer', () => {
999 'Loading (2)',
1000 'Loading (3)',
1001 ]);
990 - expect(ReactNoop.getChildren()).toEqual([
991 - span('Loading (1)'),
992 - span('Loading (2)'),
993 - span('Loading (3)'),
994 - ]);
1002 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
1003 + <React.Fragment>
1004 + <span hidden={true} prop="Sibling" />
1005 + <span prop="Loading (1)" />
1006 + <span prop="Loading (2)" />
1007 + <span prop="Loading (3)" />
1008 + </React.Fragment>,
1009 + );
1010
1011 await advanceTimers(100);
1012 expect(ReactNoop.flush()).toEqual([
1013 'Promise resolved [Step: 2]',
999 - // TODO: The state of the children is lost when switching back. Revisit
1000 - // this in the follow up PR.
1001 - 'Step: 1',
1002 - 'Sibling',
1003 - ]);
1004 - expect(ReactNoop.getChildren()).toEqual([
1005 - span('Step: 1'),
1006 - span('Sibling'),
1014 + 'Step: 2',
1015 ]);
1016 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
1017 + <React.Fragment>
1018 + <span prop="Step: 2" />
1019 + <span prop="Sibling" />
1020 + </React.Fragment>,
1021 + );
1022 });
1023
1024 it(
@@ -1203,19 +1217,19 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1217 // The placeholder is rendered in a subsequent commit
1218 'Loading...',
1219 'Promise resolved [Async: 1]',
1206 - 'Before',
1220 'Async: 1',
1208 - 'After',
1209 - ]);
1210 - expect(ReactNoop.getChildren()).toEqual([
1211 - span('Before'),
1212 - span('Async: 1'),
1213 - span('After'),
1214 -
1215 - span('Before'),
1216 - span('Sync: 1'),
1217 - span('After'),
1221 ]);
1222 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
1223 + <React.Fragment>
1224 + <span prop="Before" />
1225 + <span prop="Async: 1" />
1226 + <span prop="After" />
1227 +
1228 + <span prop="Before" />
1229 + <span prop="Sync: 1" />
1230 + <span prop="After" />
1231 + </React.Fragment>,
1232 + );
1233
1234 // Update. This starts out asynchronously.
1235 text1.current.setState({text: 'Async: 2'}, () =>
@@ -1244,35 +1258,37 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1258 // Switch to the placeholder in a subsequent commit
1259 'Loading...',
1260 ]);
1247 - expect(ReactNoop.getChildren()).toEqual([
1248 - span('Loading...'),
1249 -
1250 - span('Before'),
1251 - span('Sync: 2'),
1252 - span('After'),
1253 - ]);
1261 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
1262 + <React.Fragment>
1263 + <span hidden={true} prop="Before" />
1264 + <span hidden={true} prop="After" />
1265 + <span prop="Loading..." />
1266 +
1267 + <span prop="Before" />
1268 + <span prop="Sync: 2" />
1269 + <span prop="After" />
1270 + </React.Fragment>,
1271 + );
1272
1273 // When the placeholder is pinged, the boundary must be re-rendered
1274 // synchronously.
1275 await advanceTimers(100);
1276 expect(ReactNoop.clearYields()).toEqual([
1277 'Promise resolved [Async: 2]',
1260 - 'Before',
1261 - 'Async: 1',
1262 - 'After',
1278 + 'Async: 2',
1279 ]);
1280
1265 - expect(ReactNoop.getChildren()).toEqual([
1266 - span('Before'),
1267 - // TODO: The state of the children is lost when switching back. Revisit
1268 - // this in the follow up PR.
1269 - span('Async: 1'),
1270 - span('After'),
1281 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
1282 + <React.Fragment>
1283 + <span prop="Before" />
1284 + <span prop="Async: 2" />
1285 + <span prop="After" />
1286
1272 - span('Before'),
1273 - span('Sync: 2'),
1274 - span('After'),
1275 - ]);
1287 + <span prop="Before" />
1288 + <span prop="Sync: 2" />
1289 + <span prop="After" />
1290 + </React.Fragment>,
1291 + );
1292 },
1293 );
1294
@@ -1332,24 +1348,25 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1348 // placeholder. This should be a mount, not an update.
1349 'Mount [Loading...]',
1350 ]);
1335 - expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1351 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
1352 + <React.Fragment>
1353 + <span hidden={true} prop="A" />
1354 + <span hidden={true} prop="C" />
1355
1337 - await advanceTimers(1000);
1338 - expect(ReactNoop.expire(1000)).toEqual([
1339 - 'Promise resolved [B]',
1340 - 'A',
1341 - 'B',
1342 - 'C',
1343 - 'Mount [A]',
1344 - 'Mount [B]',
1345 - 'Mount [C]',
1346 - ]);
1356 + <span prop="Loading..." />
1357 + </React.Fragment>,
1358 + );
1359
1348 - expect(ReactNoop.getChildren()).toEqual([
1349 - span('A'),
1350 - span('B'),
1351 - span('C'),
1352 - ]);
1360 + await advanceTimers(1000);
1361 + expect(ReactNoop.expire(1000)).toEqual(['Promise resolved [B]', 'B']);
1362 +
1363 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
1364 + <React.Fragment>
1365 + <span prop="A" />
1366 + <span prop="B" />
1367 + <span prop="C" />
1368 + </React.Fragment>,
1369 + );
1370 });
1371
1372 it('suspends inside constructor', async () => {
@@ -1357,6 +1374,7 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1374 constructor(props) {
1375 super(props);
1376 const text = props.text;
1377 + ReactNoop.yield('constructor');
1378 try {
1379 TextResource.read(cache, [props.text, props.ms]);
1380 this.state = {text};
@@ -1369,6 +1387,9 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1387 throw promise;
1388 }
1389 }
1390 + componentDidMount() {
1391 + ReactNoop.yield('componentDidMount');
1392 + }
1393 render() {
1394 ReactNoop.yield(this.state.text);
1395 return <span prop={this.state.text} />;
@@ -1381,7 +1402,21 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1402 </Suspense>,
1403 );
1404
1405 + expect(ReactNoop.clearYields()).toEqual([
1406 + 'constructor',
1407 + 'Suspend! [Hi]',
1408 + 'Loading...',
1409 + ]);
1410 expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1411 +
1412 + await advanceTimers(1000);
1413 + expect(ReactNoop.clearYields()).toEqual([
1414 + 'Promise resolved [Hi]',
1415 + 'constructor',
1416 + 'Hi',
1417 + 'componentDidMount',
1418 + ]);
1419 + expect(ReactNoop.getChildren()).toEqual([span('Hi')]);
1420 });
1421
1422 it('does not infinite loop if fallback contains lifecycle method', async () => {
@@ -1776,16 +1811,15 @@ describe('ReactSuspenseWithNoopRenderer', () => {
1811
1812 // In a subsequent commit, render a placeholder
1813 'Loading...',
1779 -
1780 - // A, B, and C are unmounted, but we skip calling B's componentWillUnmount
1781 - 'Unmount [A]',
1782 - 'Unmount [C]',
1783 -
1784 - // Force delete all the existing children when switching to the
1785 - // placeholder. This should be a mount, not an update.
1814 'Mount [Loading...]',
1815 ]);
1788 - expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
1816 + expect(ReactNoop.getChildrenAsJSX()).toEqual(
1817 + <React.Fragment>
1818 + <span hidden={true} prop="A" />
1819 + <span hidden={true} prop="C" />
1820 + <span prop="Loading..." />
1821 + </React.Fragment>,
1822 + );
1823 });
1824 });
1825
packages/react-reconciler/src/__tests__/ReactTopLevelText-test.js
+2 -2
@@ -26,13 +26,13 @@ describe('ReactTopLevelText', () => {
26 const Text = ({value}) => value;
27 ReactNoop.render(<Text value="foo" />);
28 ReactNoop.flush();
29 - expect(ReactNoop.getChildren()).toEqual([{text: 'foo'}]);
29 + expect(ReactNoop.getChildrenAsJSX()).toEqual('foo');
30 });
31
32 it('should render a component returning numbers directly from render', () => {
33 const Text = ({value}) => value;
34 ReactNoop.render(<Text value={10} />);
35 ReactNoop.flush();
36 - expect(ReactNoop.getChildren()).toEqual([{text: '10'}]);
36 + expect(ReactNoop.getChildrenAsJSX()).toEqual('10');
37 });
38 });
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+7
@@ -75,6 +75,10 @@ export const insertInContainerBefore = $$$hostConfig.insertInContainerBefore;
75 export const removeChild = $$$hostConfig.removeChild;
76 export const removeChildFromContainer = $$$hostConfig.removeChildFromContainer;
77 export const resetTextContent = $$$hostConfig.resetTextContent;
78 +export const hideInstance = $$$hostConfig.hideInstance;
79 +export const hideTextInstance = $$$hostConfig.hideTextInstance;
80 +export const unhideInstance = $$$hostConfig.unhideInstance;
81 +export const unhideTextInstance = $$$hostConfig.unhideTextInstance;
82
83 // -------------------
84 // Persistence
@@ -87,6 +91,9 @@ export const appendChildToContainerChildSet =
91 export const finalizeContainerChildren =
92 $$$hostConfig.finalizeContainerChildren;
93 export const replaceContainerChildren = $$$hostConfig.replaceContainerChildren;
94 +export const cloneHiddenInstance = $$$hostConfig.cloneHiddenInstance;
95 +export const cloneUnhiddenInstance = $$$hostConfig.cloneUnhiddenInstance;
96 +export const createHiddenTextInstance = $$$hostConfig.createHiddenTextInstance;
97
98 // -------------------
99 // Hydration
packages/react-test-renderer/src/ReactTestHostConfig.js
+23
@@ -20,12 +20,14 @@ export type Container = {|
20 export type Instance = {|
21 type: string,
22 props: Object,
23 + isHidden: boolean,
24 children: Array<Instance | TextInstance>,
25 rootContainerInstance: Container,
26 tag: 'INSTANCE',
27 |};
28 export type TextInstance = {|
29 text: string,
30 + isHidden: boolean,
31 tag: 'TEXT',
32 |};
33 export type HydratableInstance = Instance | TextInstance;
@@ -132,6 +134,7 @@ export function createInstance(
134 return {
135 type,
136 props,
137 + isHidden: false,
138 children: [],
139 rootContainerInstance,
140 tag: 'INSTANCE',
@@ -186,6 +189,7 @@ export function createTextInstance(
189 ): TextInstance {
190 return {
191 text,
192 + isHidden: false,
193 tag: 'TEXT',
194 };
195 }
@@ -245,3 +249,22 @@ export function resetTextContent(testElement: Instance): void {
249 export const appendChildToContainer = appendChild;
250 export const insertInContainerBefore = insertBefore;
251 export const removeChildFromContainer = removeChild;
252 +
253 +export function hideInstance(instance: Instance): void {
254 + instance.isHidden = true;
255 +}
256 +
257 +export function hideTextInstance(textInstance: TextInstance): void {
258 + textInstance.isHidden = true;
259 +}
260 +
261 +export function unhideInstance(instance: Instance, props: Props): void {
262 + instance.isHidden = false;
263 +}
264 +
265 +export function unhideTextInstance(
266 + textInstance: TextInstance,
267 + text: string,
268 +): void {
269 + textInstance.isHidden = false;
270 +}
packages/react-test-renderer/src/ReactTestRenderer.js
+34 -4
@@ -66,11 +66,17 @@ const defaultTestOptions = {
66 },
67 };
68
69 -function toJSON(inst: Instance | TextInstance): ReactTestRendererNode {
69 +function toJSON(inst: Instance | TextInstance): ReactTestRendererNode | null {
70 + if (inst.isHidden) {
71 + // Omit timed out children from output entirely. This seems like the least
72 + // surprising behavior. We could perhaps add a separate API that includes
73 + // them, if it turns out people need it.
74 + return null;
75 + }
76 switch (inst.tag) {
77 case 'TEXT':
78 return inst.text;
73 - case 'INSTANCE':
79 + case 'INSTANCE': {
80 /* eslint-disable no-unused-vars */
81 // We don't include the `children` prop in JSON.
82 // Instead, we will include the actual rendered children.
@@ -78,7 +84,16 @@ function toJSON(inst: Instance | TextInstance): ReactTestRendererNode {
84 /* eslint-enable */
85 let renderedChildren = null;
86 if (inst.children && inst.children.length) {
81 - renderedChildren = inst.children.map(toJSON);
87 + for (let i = 0; i < inst.children.length; i++) {
88 + const renderedChild = toJSON(inst.children[i]);
89 + if (renderedChild !== null) {
90 + if (renderedChildren === null) {
91 + renderedChildren = [renderedChild];
92 + } else {
93 + renderedChildren.push(renderedChild);
94 + }
95 + }
96 + }
97 }
98 const json: ReactTestRendererJSON = {
99 type: inst.type,
@@ -89,6 +104,7 @@ function toJSON(inst: Instance | TextInstance): ReactTestRendererNode {
104 value: Symbol.for('react.test.json'),
105 });
106 return json;
107 + }
108 default:
109 throw new Error(`Unexpected node type in toJSON: ${inst.tag}`);
110 }
@@ -460,7 +476,21 @@ const ReactTestRendererFiber = {
476 if (container.children.length === 1) {
477 return toJSON(container.children[0]);
478 }
463 - return container.children.map(toJSON);
479 +
480 + let renderedChildren = null;
481 + if (container.children && container.children.length) {
482 + for (let i = 0; i < container.children.length; i++) {
483 + const renderedChild = toJSON(container.children[i]);
484 + if (renderedChild !== null) {
485 + if (renderedChildren === null) {
486 + renderedChildren = [renderedChild];
487 + } else {
488 + renderedChildren.push(renderedChild);
489 + }
490 + }
491 + }
492 + }
493 + return renderedChildren;
494 },
495 toTree() {
496 if (root == null || root.current == null) {
packages/react/src/__tests__/ReactProfiler-test.internal.js
+3 -9
@@ -2252,7 +2252,7 @@ describe('Profiler', () => {
2252 'Monkey',
2253 ]);
2254 // The update hasn't expired yet, so we commit nothing.
2255 - expect(ReactNoop.getChildren()).toEqual([]);
2255 + expect(ReactNoop.getChildrenAsJSX()).toEqual(null);
2256 expect(onRender).not.toHaveBeenCalled();
2257
2258 // Advance both React's virtual time and Jest's timers by enough to expire
@@ -2263,10 +2263,7 @@ describe('Profiler', () => {
2263 // the placeholder.
2264 expect(ReactNoop.flushExpired()).toEqual([]);
2265 // Should have committed the placeholder.
2266 - expect(ReactNoop.getChildren()).toEqual([
2267 - {text: 'Loading...'},
2268 - {text: 'Sync'},
2269 - ]);
2266 + expect(ReactNoop.getChildrenAsJSX()).toEqual('Loading...Sync');
2267 expect(onRender).toHaveBeenCalledTimes(1);
2268
2269 let call = onRender.mock.calls[0];
@@ -2289,10 +2286,7 @@ describe('Profiler', () => {
2286 'Promise resolved [Async]',
2287 'AsyncText [Async]',
2288 ]);
2292 - expect(ReactNoop.getChildren()).toEqual([
2293 - {text: 'Async'},
2294 - {text: 'Sync'},
2295 - ]);
2289 + expect(ReactNoop.getChildrenAsJSX()).toEqual('AsyncSync');
2290 expect(onRender).toHaveBeenCalledTimes(3);
2291
2292 call = onRender.mock.calls[2];
packages/react/src/__tests__/forwardRef-test.js
+8 -2
@@ -102,11 +102,17 @@ describe('forwardRef', () => {
102 <RefForwardingComponent ref={ref} optional="foo" required="bar" />,
103 );
104 ReactNoop.flush();
105 - expect(ref.current.children).toEqual([{text: 'foo'}, {text: 'bar'}]);
105 + expect(ref.current.children).toEqual([
106 + {text: 'foo', hidden: false},
107 + {text: 'bar', hidden: false},
108 + ]);
109
110 ReactNoop.render(<RefForwardingComponent ref={ref} required="foo" />);
111 ReactNoop.flush();
109 - expect(ref.current.children).toEqual([{text: 'default'}, {text: 'foo'}]);
112 + expect(ref.current.children).toEqual([
113 + {text: 'default', hidden: false},
114 + {text: 'foo', hidden: false},
115 + ]);
116
117 expect(() =>
118 ReactNoop.render(<RefForwardingComponent ref={ref} optional="foo" />),
packages/shared/HostConfigWithNoMutation.js
+4
@@ -33,3 +33,7 @@ export const insertInContainerBefore = shim;
33 export const removeChild = shim;
34 export const removeChildFromContainer = shim;
35 export const resetTextContent = shim;
36 +export const hideInstance = shim;
37 +export const hideTextInstance = shim;
38 +export const unhideInstance = shim;
39 +export const unhideTextInstance = shim;
packages/shared/HostConfigWithNoPersistence.js
+3
@@ -28,3 +28,6 @@ export const createContainerChildSet = shim;
28 export const appendChildToContainerChildSet = shim;
29 export const finalizeContainerChildren = shim;
30 export const replaceContainerChildren = shim;
31 +export const cloneHiddenInstance = shim;
32 +export const cloneUnhiddenInstance = shim;
33 +export const createHiddenTextInstance = shim;