Skip to content

Commit 1d1e49c

Browse files
authored
[Fizz] Assign an ID to the first DOM element in a fallback or insert a dummy (and testing infra) (#21020)
* Patches * Add Fizz testing infra structure * Assign an ID to the first DOM node in a fallback or insert a dummy * unstable_createRoot
1 parent 533aed8 commit 1d1e49c

File tree

5 files changed

+389
-21
lines changed

5 files changed

+389
-21
lines changed
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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+
* @emails react-core
8+
*/
9+
10+
'use strict';
11+
12+
let JSDOM;
13+
let Stream;
14+
let Scheduler;
15+
let React;
16+
let ReactDOM;
17+
let ReactDOMFizzServer;
18+
let Suspense;
19+
let textCache;
20+
let document;
21+
let writable;
22+
let buffer = '';
23+
let hasErrored = false;
24+
let fatalError = undefined;
25+
26+
describe('ReactDOMFizzServer', () => {
27+
beforeEach(() => {
28+
jest.resetModules();
29+
JSDOM = require('jsdom').JSDOM;
30+
Scheduler = require('scheduler');
31+
React = require('react');
32+
ReactDOM = require('react-dom');
33+
if (__EXPERIMENTAL__) {
34+
ReactDOMFizzServer = require('react-dom/unstable-fizz');
35+
}
36+
Stream = require('stream');
37+
Suspense = React.Suspense;
38+
textCache = new Map();
39+
40+
// Test Environment
41+
const jsdom = new JSDOM('<!DOCTYPE html><html><head></head><body>', {
42+
runScripts: 'dangerously',
43+
});
44+
document = jsdom.window.document;
45+
46+
buffer = '';
47+
hasErrored = false;
48+
49+
writable = new Stream.PassThrough();
50+
writable.setEncoding('utf8');
51+
writable.on('data', chunk => {
52+
buffer += chunk;
53+
});
54+
writable.on('error', error => {
55+
hasErrored = true;
56+
fatalError = error;
57+
});
58+
});
59+
60+
async function act(callback) {
61+
await callback();
62+
// Await one turn around the event loop.
63+
// This assumes that we'll flush everything we have so far.
64+
await new Promise(resolve => {
65+
setImmediate(resolve);
66+
});
67+
if (hasErrored) {
68+
throw fatalError;
69+
}
70+
// JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
71+
// We also want to execute any scripts that are embedded.
72+
// We assume that we have now received a proper fragment of HTML.
73+
const bufferedContent = buffer;
74+
buffer = '';
75+
const fakeBody = document.createElement('body');
76+
fakeBody.innerHTML = bufferedContent;
77+
while (fakeBody.firstChild) {
78+
const node = fakeBody.firstChild;
79+
if (node.nodeName === 'SCRIPT') {
80+
const script = document.createElement('script');
81+
script.textContent = node.textContent;
82+
fakeBody.removeChild(node);
83+
document.body.appendChild(script);
84+
} else {
85+
document.body.appendChild(node);
86+
}
87+
}
88+
}
89+
90+
function getVisibleChildren(element) {
91+
const children = [];
92+
let node = element.firstChild;
93+
while (node) {
94+
if (node.nodeType === 1) {
95+
if (node.tagName !== 'SCRIPT' && !node.hasAttribute('hidden')) {
96+
const props = {};
97+
const attributes = node.attributes;
98+
for (let i = 0; i < attributes.length; i++) {
99+
props[attributes[i].name] = attributes[i].value;
100+
}
101+
props.children = getVisibleChildren(node);
102+
children.push(React.createElement(node.tagName.toLowerCase(), props));
103+
}
104+
} else if (node.nodeType === 3) {
105+
children.push(node.data);
106+
}
107+
node = node.nextSibling;
108+
}
109+
return children.length === 0
110+
? null
111+
: children.length === 1
112+
? children[0]
113+
: children;
114+
}
115+
116+
function resolveText(text) {
117+
const record = textCache.get(text);
118+
if (record === undefined) {
119+
const newRecord = {
120+
status: 'resolved',
121+
value: text,
122+
};
123+
textCache.set(text, newRecord);
124+
} else if (record.status === 'pending') {
125+
const thenable = record.value;
126+
record.status = 'resolved';
127+
record.value = text;
128+
thenable.pings.forEach(t => t());
129+
}
130+
}
131+
132+
/*
133+
function rejectText(text, error) {
134+
const record = textCache.get(text);
135+
if (record === undefined) {
136+
const newRecord = {
137+
status: 'rejected',
138+
value: error,
139+
};
140+
textCache.set(text, newRecord);
141+
} else if (record.status === 'pending') {
142+
const thenable = record.value;
143+
record.status = 'rejected';
144+
record.value = error;
145+
thenable.pings.forEach(t => t());
146+
}
147+
}
148+
*/
149+
150+
function readText(text) {
151+
const record = textCache.get(text);
152+
if (record !== undefined) {
153+
switch (record.status) {
154+
case 'pending':
155+
throw record.value;
156+
case 'rejected':
157+
throw record.value;
158+
case 'resolved':
159+
return record.value;
160+
}
161+
} else {
162+
const thenable = {
163+
pings: [],
164+
then(resolve) {
165+
if (newRecord.status === 'pending') {
166+
thenable.pings.push(resolve);
167+
} else {
168+
Promise.resolve().then(() => resolve(newRecord.value));
169+
}
170+
},
171+
};
172+
173+
const newRecord = {
174+
status: 'pending',
175+
value: thenable,
176+
};
177+
textCache.set(text, newRecord);
178+
179+
throw thenable;
180+
}
181+
}
182+
183+
function Text({text}) {
184+
return text;
185+
}
186+
187+
function AsyncText({text}) {
188+
return readText(text);
189+
}
190+
191+
// @gate experimental
192+
it('should asynchronously load the suspense boundary', async () => {
193+
await act(async () => {
194+
ReactDOMFizzServer.pipeToNodeWritable(
195+
<div>
196+
<Suspense fallback={<Text text="Loading..." />}>
197+
<AsyncText text="Hello World" />
198+
</Suspense>
199+
</div>,
200+
writable,
201+
);
202+
});
203+
expect(getVisibleChildren(document.body)).toEqual(<div>Loading...</div>);
204+
await act(async () => {
205+
resolveText('Hello World');
206+
});
207+
expect(getVisibleChildren(document.body)).toEqual(<div>Hello World</div>);
208+
});
209+
210+
// @gate experimental
211+
it('waits for pending content to come in from the server and then hydrates it', async () => {
212+
const ref = React.createRef();
213+
214+
function App() {
215+
return (
216+
<div>
217+
<Suspense fallback="Loading...">
218+
<h1 ref={ref}>
219+
<AsyncText text="Hello" />
220+
</h1>
221+
</Suspense>
222+
</div>
223+
);
224+
}
225+
226+
await act(async () => {
227+
ReactDOMFizzServer.pipeToNodeWritable(
228+
// We currently have to wrap the server node in a container because
229+
// otherwise the Fizz nodes get deleted during hydration.
230+
<div id="container">
231+
<App />
232+
</div>,
233+
writable,
234+
);
235+
});
236+
237+
// We're still showing a fallback.
238+
239+
// Attempt to hydrate the content.
240+
const container = document.body.firstChild;
241+
const root = ReactDOM.unstable_createRoot(container, {hydrate: true});
242+
root.render(<App />);
243+
Scheduler.unstable_flushAll();
244+
245+
// We're still loading because we're waiting for the server to stream more content.
246+
expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
247+
248+
// The server now updates the content in place in the fallback.
249+
await act(async () => {
250+
resolveText('Hello');
251+
});
252+
253+
// The final HTML is now in place.
254+
expect(getVisibleChildren(container)).toEqual(
255+
<div>
256+
<h1>Hello</h1>
257+
</div>,
258+
);
259+
const h1 = container.getElementsByTagName('h1')[0];
260+
261+
// But it is not yet hydrated.
262+
expect(ref.current).toBe(null);
263+
264+
Scheduler.unstable_flushAll();
265+
266+
// Now it's hydrated.
267+
expect(ref.current).toBe(h1);
268+
});
269+
});

0 commit comments

Comments
 (0)