Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit dac9202

Browse files
author
Andrew Clark
authored
Hide timed-out children instead of deleting them so their state is preserved (facebook#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
1 parent 4f0bd45 commit dac9202

36 files changed

+1991
-367
lines changed

fixtures/dom/src/components/Header.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ class Header extends React.Component {
6868
<option value="/pointer-events">Pointer Events</option>
6969
<option value="/mouse-events">Mouse Events</option>
7070
<option value="/selection-events">Selection Events</option>
71+
<option value="/suspense">Suspense</option>
7172
</select>
7273
</label>
7374
<label htmlFor="react_version">
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export function toFlushAndThrow(root, ...rest) {
8585
}
8686

8787
export function toMatchRenderedOutput(root, expectedJSX) {
88+
assertYieldsWereCleared(root);
8889
const actualJSON = root.toJSON();
8990

9091
let actualJSX;
@@ -151,7 +152,7 @@ function jsonChildrenToJSXChildren(jsonChildren) {
151152
let allJSXChildrenAreStrings = true;
152153
let jsxChildrenString = '';
153154
for (let i = 0; i < jsonChildren.length; i++) {
154-
const jsxChild = jsonChildrenToJSXChildren(jsonChildren[i]);
155+
const jsxChild = jsonChildToJSXChild(jsonChildren[i]);
155156
jsxChildren.push(jsxChild);
156157
if (allJSXChildrenAreStrings) {
157158
if (typeof jsxChild === 'string') {

packages/react-art/src/ReactARTHostConfig.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,3 +405,21 @@ export function commitUpdate(
405405
) {
406406
instance._applyProps(instance, newProps, oldProps);
407407
}
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+
}

0 commit comments

Comments
 (0)