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
+let JSDOM;
11
+let React;
12
+let ReactDOM;
13
+let Scheduler;
14
+let clientAct;
15
+let ReactDOMFizzServer;
16
+let Stream;
17
+let document;
18
+let writable;
19
+let container;
20
+let buffer = '';
21
+let hasErrored = false;
22
+let fatalError = undefined;
23
+let textCache;
24
+
25
+describe('ReactDOMFizzShellHydration', () => {
26
+ beforeEach(() => {
27
+ jest.resetModules();
28
+ JSDOM = require('jsdom').JSDOM;
29
+ React = require('react');
30
+ ReactDOM = require('react-dom');
31
+ Scheduler = require('scheduler');
32
+ clientAct = require('jest-react').act;
33
+ ReactDOMFizzServer = require('react-dom/server');
34
+ Stream = require('stream');
35
+
36
+ textCache = new Map();
37
+
38
+ // Test Environment
39
+ const jsdom = new JSDOM(
40
+ '<!DOCTYPE html><html><head></head><body><div id="container">',
41
+ {
42
+ runScripts: 'dangerously',
43
+ },
44
+ );
45
+ document = jsdom.window.document;
46
+ container = document.getElementById('container');
47
+
48
+ buffer = '';
49
+ hasErrored = false;
50
+
51
+ writable = new Stream.PassThrough();
52
+ writable.setEncoding('utf8');
53
+ writable.on('data', chunk => {
54
+ buffer += chunk;
55
+ });
56
+ writable.on('error', error => {
57
+ hasErrored = true;
58
+ fatalError = error;
59
+ });
60
+ });
61
+
62
+ async function serverAct(callback) {
63
+ await callback();
64
+ // Await one turn around the event loop.
65
+ // This assumes that we'll flush everything we have so far.
66
+ await new Promise(resolve => {
67
+ setImmediate(resolve);
68
+ });
69
+ if (hasErrored) {
70
+ throw fatalError;
71
+ }
72
+ // JSDOM doesn't support stream HTML parser so we need to give it a proper fragment.
73
+ // We also want to execute any scripts that are embedded.
74
+ // We assume that we have now received a proper fragment of HTML.
75
+ const bufferedContent = buffer;
76
+ buffer = '';
77
+ const fakeBody = document.createElement('body');
78
+ fakeBody.innerHTML = bufferedContent;
79
+ while (fakeBody.firstChild) {
80
+ const node = fakeBody.firstChild;
81
+ if (node.nodeName === 'SCRIPT') {
82
+ const script = document.createElement('script');
83
+ script.textContent = node.textContent;
84
+ fakeBody.removeChild(node);
85
+ container.appendChild(script);
86
+ } else {
87
+ container.appendChild(node);
88
+ }
89
+ }
90
+ }
91
+
92
+ function resolveText(text) {
93
+ const record = textCache.get(text);
94
+ if (record === undefined) {
95
+ const newRecord = {
96
+ status: 'resolved',
97
+ value: text,
98
+ };
99
+ textCache.set(text, newRecord);
100
+ } else if (record.status === 'pending') {
101
+ const thenable = record.value;
102
+ record.status = 'resolved';
103
+ record.value = text;
104
+ thenable.pings.forEach(t => t());
105
+ }
106
+ }
107
+
108
+ function readText(text) {
109
+ const record = textCache.get(text);
110
+ if (record !== undefined) {
111
+ switch (record.status) {
112
+ case 'pending':
113
+ throw record.value;
114
+ case 'rejected':
115
+ throw record.value;
116
+ case 'resolved':
117
+ return record.value;
118
+ }
119
+ } else {
120
+ Scheduler.unstable_yieldValue(`Suspend! [${text}]`);
121
+
122
+ const thenable = {
123
+ pings: [],
124
+ then(resolve) {
125
+ if (newRecord.status === 'pending') {
126
+ thenable.pings.push(resolve);
127
+ } else {
128
+ Promise.resolve().then(() => resolve(newRecord.value));
129
+ }
130
+ },
131
+ };
132
+
133
+ const newRecord = {
134
+ status: 'pending',
135
+ value: thenable,
136
+ };
137
+ textCache.set(text, newRecord);
138
+
139
+ throw thenable;
140
+ }
141
+ }
142
+
143
+ // function Text({text}) {
144
+ // Scheduler.unstable_yieldValue(text);
145
+ // return text;
146
+ // }
147
+
148
+ function AsyncText({text}) {
149
+ readText(text);
150
+ Scheduler.unstable_yieldValue(text);
151
+ return text;
152
+ }
153
+
154
+ function resetTextCache() {
155
+ textCache = new Map();
156
+ }
157
+
158
+ test('suspending in the shell during hydration', async () => {
159
+ const div = React.createRef(null);
160
+
161
+ function App() {
162
+ return (
163
+ <div ref={div}>
164
+ <AsyncText text="Shell" />
165
+ </div>
166
+ );
167
+ }
168
+
169
+ // Server render
170
+ await resolveText('Shell');
171
+ await serverAct(async () => {
172
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
173
+ pipe(writable);
174
+ });
175
+ expect(Scheduler).toHaveYielded(['Shell']);
176
+ const dehydratedDiv = container.getElementsByTagName('div')[0];
177
+
178
+ // Clear the cache and start rendering on the client
179
+ resetTextCache();
180
+
181
+ // Hydration suspends because the data for the shell hasn't loaded yet
182
+ await clientAct(async () => {
183
+ ReactDOM.hydrateRoot(container, <App />);
184
+ });
185
+ expect(Scheduler).toHaveYielded(['Suspend! [Shell]']);
186
+ expect(div.current).toBe(null);
187
+ expect(container.textContent).toBe('Shell');
188
+
189
+ // The shell loads and hydration finishes
190
+ await clientAct(async () => {
191
+ await resolveText('Shell');
192
+ });
193
+ expect(Scheduler).toHaveYielded(['Shell']);
194
+ expect(div.current).toBe(dehydratedDiv);
195
+ expect(container.textContent).toBe('Shell');
196
+ });
197
+
198
+ test('suspending in the shell during a normal client render', async () => {
199
+ // Same as previous test but during a normal client render, no hydration
200
+ function App() {
201
+ return <AsyncText text="Shell" />;
202
+ }
203
+
204
+ const root = ReactDOM.createRoot(container);
205
+ await clientAct(async () => {
206
+ root.render(<App />);
207
+ });
208
+ expect(Scheduler).toHaveYielded(['Suspend! [Shell]']);
209
+
210
+ await clientAct(async () => {
211
+ await resolveText('Shell');
212
+ });
213
+ expect(Scheduler).toHaveYielded(['Shell']);
214
+ expect(container.textContent).toBe('Shell');
215
+ });
216
+});