Skip to content

Commit b798942

Browse files
authored
[Flight] Add support for Webpack Async Modules (#25138)
This lets you await the result of require(...) which will then mark the result as async which will then let the client unwrap the Promise before handing it over in the same way.
1 parent c8b778b commit b798942

5 files changed

+202
-13
lines changed

packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js

+57-11
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* @flow
88
*/
99

10+
import type {Thenable} from 'shared/ReactTypes';
11+
1012
export type WebpackSSRMap = {
1113
[clientId: string]: {
1214
[clientExportName: string]: ModuleMetaData,
@@ -19,6 +21,7 @@ export opaque type ModuleMetaData = {
1921
id: string,
2022
chunks: Array<string>,
2123
name: string,
24+
async: boolean,
2225
};
2326

2427
// eslint-disable-next-line no-unused-vars
@@ -29,7 +32,17 @@ export function resolveModuleReference<T>(
2932
moduleData: ModuleMetaData,
3033
): ModuleReference<T> {
3134
if (bundlerConfig) {
32-
return bundlerConfig[moduleData.id][moduleData.name];
35+
const resolvedModuleData = bundlerConfig[moduleData.id][moduleData.name];
36+
if (moduleData.async) {
37+
return {
38+
id: resolvedModuleData.id,
39+
chunks: resolvedModuleData.chunks,
40+
name: resolvedModuleData.name,
41+
async: true,
42+
};
43+
} else {
44+
return resolvedModuleData;
45+
}
3346
}
3447
return moduleData;
3548
}
@@ -39,39 +52,72 @@ export function resolveModuleReference<T>(
3952
// in Webpack but unfortunately it's not exposed so we have to
4053
// replicate it in user space. null means that it has already loaded.
4154
const chunkCache: Map<string, null | Promise<any> | Error> = new Map();
55+
const asyncModuleCache: Map<string, Thenable<any>> = new Map();
4256

4357
// Start preloading the modules since we might need them soon.
4458
// This function doesn't suspend.
4559
export function preloadModule<T>(moduleData: ModuleReference<T>): void {
4660
const chunks = moduleData.chunks;
61+
const promises = [];
4762
for (let i = 0; i < chunks.length; i++) {
4863
const chunkId = chunks[i];
4964
const entry = chunkCache.get(chunkId);
5065
if (entry === undefined) {
5166
const thenable = __webpack_chunk_load__(chunkId);
67+
promises.push(thenable);
5268
const resolve = chunkCache.set.bind(chunkCache, chunkId, null);
5369
const reject = chunkCache.set.bind(chunkCache, chunkId);
5470
thenable.then(resolve, reject);
5571
chunkCache.set(chunkId, thenable);
5672
}
5773
}
74+
if (moduleData.async) {
75+
const modulePromise: any = Promise.all(promises).then(() => {
76+
return __webpack_require__(moduleData.id);
77+
});
78+
modulePromise.then(
79+
value => {
80+
modulePromise.status = 'fulfilled';
81+
modulePromise.value = value;
82+
},
83+
reason => {
84+
modulePromise.status = 'rejected';
85+
modulePromise.reason = reason;
86+
},
87+
);
88+
asyncModuleCache.set(moduleData.id, modulePromise);
89+
}
5890
}
5991

6092
// Actually require the module or suspend if it's not yet ready.
6193
// Increase priority if necessary.
6294
export function requireModule<T>(moduleData: ModuleReference<T>): T {
63-
const chunks = moduleData.chunks;
64-
for (let i = 0; i < chunks.length; i++) {
65-
const chunkId = chunks[i];
66-
const entry = chunkCache.get(chunkId);
67-
if (entry !== null) {
68-
// We assume that preloadModule has been called before.
69-
// So we don't expect to see entry being undefined here, that's an error.
70-
// Let's throw either an error or the Promise.
71-
throw entry;
95+
let moduleExports;
96+
if (moduleData.async) {
97+
// We assume that preloadModule has been called before, which
98+
// should have added something to the module cache.
99+
const promise: any = asyncModuleCache.get(moduleData.id);
100+
if (promise.status === 'fulfilled') {
101+
moduleExports = promise.value;
102+
} else if (promise.status === 'rejected') {
103+
throw promise.reason;
104+
} else {
105+
throw promise;
106+
}
107+
} else {
108+
const chunks = moduleData.chunks;
109+
for (let i = 0; i < chunks.length; i++) {
110+
const chunkId = chunks[i];
111+
const entry = chunkCache.get(chunkId);
112+
if (entry !== null) {
113+
// We assume that preloadModule has been called before.
114+
// So we don't expect to see entry being undefined here, that's an error.
115+
// Let's throw either an error or the Promise.
116+
throw entry;
117+
}
72118
}
119+
moduleExports = __webpack_require__(moduleData.id);
73120
}
74-
const moduleExports = __webpack_require__(moduleData.id);
75121
if (moduleData.name === '*') {
76122
// This is a placeholder value that represents that the caller imported this
77123
// as a CommonJS module as is.

packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js

+20-2
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,27 @@ export type ModuleReference<T> = {
2020
$$typeof: Symbol,
2121
filepath: string,
2222
name: string,
23+
async: boolean,
2324
};
2425

2526
export type ModuleMetaData = {
2627
id: string,
2728
chunks: Array<string>,
2829
name: string,
30+
async: boolean,
2931
};
3032

3133
export type ModuleKey = string;
3234

3335
const MODULE_TAG = Symbol.for('react.module.reference');
3436

3537
export function getModuleKey(reference: ModuleReference<any>): ModuleKey {
36-
return reference.filepath + '#' + reference.name;
38+
return (
39+
reference.filepath +
40+
'#' +
41+
reference.name +
42+
(reference.async ? '#async' : '')
43+
);
3744
}
3845

3946
export function isModuleReference(reference: Object): boolean {
@@ -44,5 +51,16 @@ export function resolveModuleMetaData<T>(
4451
config: BundlerConfig,
4552
moduleReference: ModuleReference<T>,
4653
): ModuleMetaData {
47-
return config[moduleReference.filepath][moduleReference.name];
54+
const resolvedModuleData =
55+
config[moduleReference.filepath][moduleReference.name];
56+
if (moduleReference.async) {
57+
return {
58+
id: resolvedModuleData.id,
59+
chunks: resolvedModuleData.chunks,
60+
name: resolvedModuleData.name,
61+
async: true,
62+
};
63+
} else {
64+
return resolvedModuleData;
65+
}
4866
}

packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js

+35
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const Module = require('module');
1414

1515
module.exports = function register() {
1616
const MODULE_REFERENCE = Symbol.for('react.module.reference');
17+
const PROMISE_PROTOTYPE = Promise.prototype;
18+
1719
const proxyHandlers = {
1820
get: function(target, name, receiver) {
1921
switch (name) {
@@ -26,6 +28,8 @@ module.exports = function register() {
2628
return target.filepath;
2729
case 'name':
2830
return target.name;
31+
case 'async':
32+
return target.async;
2933
// We need to special case this because createElement reads it if we pass this
3034
// reference.
3135
case 'defaultProps':
@@ -39,19 +43,49 @@ module.exports = function register() {
3943
// This a placeholder value that tells the client to conditionally use the
4044
// whole object or just the default export.
4145
name: '',
46+
async: target.async,
4247
};
4348
return true;
49+
case 'then':
50+
if (!target.async) {
51+
// If this module is expected to return a Promise (such as an AsyncModule) then
52+
// we should resolve that with a client reference that unwraps the Promise on
53+
// the client.
54+
const then = function then(resolve, reject) {
55+
const moduleReference: {[string]: any} = {
56+
$$typeof: MODULE_REFERENCE,
57+
filepath: target.filepath,
58+
name: '*', // Represents the whole object instead of a particular import.
59+
async: true,
60+
};
61+
return Promise.resolve(
62+
resolve(new Proxy(moduleReference, proxyHandlers)),
63+
);
64+
};
65+
// If this is not used as a Promise but is treated as a reference to a `.then`
66+
// export then we should treat it as a reference to that name.
67+
then.$$typeof = MODULE_REFERENCE;
68+
then.filepath = target.filepath;
69+
// then.name is conveniently already "then" which is the export name we need.
70+
// This will break if it's minified though.
71+
return then;
72+
}
4473
}
4574
let cachedReference = target[name];
4675
if (!cachedReference) {
4776
cachedReference = target[name] = {
4877
$$typeof: MODULE_REFERENCE,
4978
filepath: target.filepath,
5079
name: name,
80+
async: target.async,
5181
};
5282
}
5383
return cachedReference;
5484
},
85+
getPrototypeOf(target) {
86+
// Pretend to be a Promise in case anyone asks.
87+
return PROMISE_PROTOTYPE;
88+
},
5589
set: function() {
5690
throw new Error('Cannot assign to a client module from a server module.');
5791
},
@@ -63,6 +97,7 @@ module.exports = function register() {
6397
$$typeof: MODULE_REFERENCE,
6498
filepath: moduleId,
6599
name: '*', // Represents the whole object instead of a particular import.
100+
async: false,
66101
};
67102
module.exports = new Proxy(moduleReference, proxyHandlers);
68103
};

packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js

+77
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,83 @@ describe('ReactFlightDOM', () => {
237237
expect(container.innerHTML).toBe('<p>@div</p>');
238238
});
239239

240+
it('should unwrap async module references', async () => {
241+
const AsyncModule = Promise.resolve(function AsyncModule({text}) {
242+
return 'Async: ' + text;
243+
});
244+
245+
const AsyncModule2 = Promise.resolve({
246+
exportName: 'Module',
247+
});
248+
249+
function Print({response}) {
250+
return <p>{response.readRoot()}</p>;
251+
}
252+
253+
function App({response}) {
254+
return (
255+
<Suspense fallback={<h1>Loading...</h1>}>
256+
<Print response={response} />
257+
</Suspense>
258+
);
259+
}
260+
261+
const AsyncModuleRef = await clientExports(AsyncModule);
262+
const AsyncModuleRef2 = await clientExports(AsyncModule2);
263+
264+
const {writable, readable} = getTestStream();
265+
const {pipe} = ReactServerDOMWriter.renderToPipeableStream(
266+
<AsyncModuleRef text={AsyncModuleRef2.exportName} />,
267+
webpackMap,
268+
);
269+
pipe(writable);
270+
const response = ReactServerDOMReader.createFromReadableStream(readable);
271+
272+
const container = document.createElement('div');
273+
const root = ReactDOMClient.createRoot(container);
274+
await act(async () => {
275+
root.render(<App response={response} />);
276+
});
277+
expect(container.innerHTML).toBe('<p>Async: Module</p>');
278+
});
279+
280+
it('should be able to import a name called "then"', async () => {
281+
const thenExports = {
282+
then: function then() {
283+
return 'and then';
284+
},
285+
};
286+
287+
function Print({response}) {
288+
return <p>{response.readRoot()}</p>;
289+
}
290+
291+
function App({response}) {
292+
return (
293+
<Suspense fallback={<h1>Loading...</h1>}>
294+
<Print response={response} />
295+
</Suspense>
296+
);
297+
}
298+
299+
const ThenRef = clientExports(thenExports).then;
300+
301+
const {writable, readable} = getTestStream();
302+
const {pipe} = ReactServerDOMWriter.renderToPipeableStream(
303+
<ThenRef />,
304+
webpackMap,
305+
);
306+
pipe(writable);
307+
const response = ReactServerDOMReader.createFromReadableStream(readable);
308+
309+
const container = document.createElement('div');
310+
const root = ReactDOMClient.createRoot(container);
311+
await act(async () => {
312+
root.render(<App response={response} />);
313+
});
314+
expect(container.innerHTML).toBe('<p>and then</p>');
315+
});
316+
240317
it('should progressively reveal server components', async () => {
241318
let reportedErrors = [];
242319

packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js

+13
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ exports.clientExports = function clientExports(moduleExports) {
5252
name: '*',
5353
},
5454
};
55+
if (typeof moduleExports.then === 'function') {
56+
moduleExports.then(asyncModuleExports => {
57+
for (const name in asyncModuleExports) {
58+
webpackMap[path] = {
59+
[name]: {
60+
id: idx,
61+
chunks: [],
62+
name: name,
63+
},
64+
};
65+
}
66+
});
67+
}
5568
for (const name in moduleExports) {
5669
webpackMap[path] = {
5770
[name]: {

0 commit comments

Comments
 (0)