Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Flight] Add support for Webpack Async Modules #25138

Merged
merged 1 commit into from
Aug 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* @flow
*/

import type {Thenable} from 'shared/ReactTypes';

export type WebpackSSRMap = {
[clientId: string]: {
[clientExportName: string]: ModuleMetaData,
Expand All @@ -19,6 +21,7 @@ export opaque type ModuleMetaData = {
id: string,
chunks: Array<string>,
name: string,
async: boolean,
};

// eslint-disable-next-line no-unused-vars
Expand All @@ -29,7 +32,17 @@ export function resolveModuleReference<T>(
moduleData: ModuleMetaData,
): ModuleReference<T> {
if (bundlerConfig) {
return bundlerConfig[moduleData.id][moduleData.name];
const resolvedModuleData = bundlerConfig[moduleData.id][moduleData.name];
if (moduleData.async) {
return {
id: resolvedModuleData.id,
chunks: resolvedModuleData.chunks,
name: resolvedModuleData.name,
async: true,
};
} else {
return resolvedModuleData;
}
}
return moduleData;
}
Expand All @@ -39,39 +52,72 @@ export function resolveModuleReference<T>(
// in Webpack but unfortunately it's not exposed so we have to
// replicate it in user space. null means that it has already loaded.
const chunkCache: Map<string, null | Promise<any> | Error> = new Map();
const asyncModuleCache: Map<string, Thenable<any>> = new Map();

// Start preloading the modules since we might need them soon.
// This function doesn't suspend.
export function preloadModule<T>(moduleData: ModuleReference<T>): void {
const chunks = moduleData.chunks;
const promises = [];
for (let i = 0; i < chunks.length; i++) {
const chunkId = chunks[i];
const entry = chunkCache.get(chunkId);
if (entry === undefined) {
const thenable = __webpack_chunk_load__(chunkId);
promises.push(thenable);
const resolve = chunkCache.set.bind(chunkCache, chunkId, null);
const reject = chunkCache.set.bind(chunkCache, chunkId);
thenable.then(resolve, reject);
chunkCache.set(chunkId, thenable);
}
}
if (moduleData.async) {
const modulePromise: any = Promise.all(promises).then(() => {
return __webpack_require__(moduleData.id);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike sync modules, we eagerly require this so that it is resolved by the time we really need it. This is unfortunate because it means we can't delay the CPU work done by initialization.

It also creates this unfortunate fork of the logic.

});
modulePromise.then(
value => {
modulePromise.status = 'fulfilled';
modulePromise.value = value;
},
reason => {
modulePromise.status = 'rejected';
modulePromise.reason = reason;
},
);
asyncModuleCache.set(moduleData.id, modulePromise);
}
}

// Actually require the module or suspend if it's not yet ready.
// Increase priority if necessary.
export function requireModule<T>(moduleData: ModuleReference<T>): T {
const chunks = moduleData.chunks;
for (let i = 0; i < chunks.length; i++) {
const chunkId = chunks[i];
const entry = chunkCache.get(chunkId);
if (entry !== null) {
// We assume that preloadModule has been called before.
// So we don't expect to see entry being undefined here, that's an error.
// Let's throw either an error or the Promise.
throw entry;
let moduleExports;
if (moduleData.async) {
// We assume that preloadModule has been called before, which
// should have added something to the module cache.
const promise: any = asyncModuleCache.get(moduleData.id);
if (promise.status === 'fulfilled') {
moduleExports = promise.value;
} else if (promise.status === 'rejected') {
throw promise.reason;
} else {
throw promise;
}
} else {
const chunks = moduleData.chunks;
for (let i = 0; i < chunks.length; i++) {
const chunkId = chunks[i];
const entry = chunkCache.get(chunkId);
if (entry !== null) {
// We assume that preloadModule has been called before.
// So we don't expect to see entry being undefined here, that's an error.
// Let's throw either an error or the Promise.
throw entry;
}
}
moduleExports = __webpack_require__(moduleData.id);
}
const moduleExports = __webpack_require__(moduleData.id);
if (moduleData.name === '*') {
// This is a placeholder value that represents that the caller imported this
// as a CommonJS module as is.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,27 @@ export type ModuleReference<T> = {
$$typeof: Symbol,
filepath: string,
name: string,
async: boolean,
};

export type ModuleMetaData = {
id: string,
chunks: Array<string>,
name: string,
async: boolean,
};

export type ModuleKey = string;

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

export function getModuleKey(reference: ModuleReference<any>): ModuleKey {
return reference.filepath + '#' + reference.name;
return (
reference.filepath +
'#' +
reference.name +
(reference.async ? '#async' : '')
);
}

export function isModuleReference(reference: Object): boolean {
Expand All @@ -44,5 +51,16 @@ export function resolveModuleMetaData<T>(
config: BundlerConfig,
moduleReference: ModuleReference<T>,
): ModuleMetaData {
return config[moduleReference.filepath][moduleReference.name];
const resolvedModuleData =
config[moduleReference.filepath][moduleReference.name];
if (moduleReference.async) {
return {
id: resolvedModuleData.id,
chunks: resolvedModuleData.chunks,
name: resolvedModuleData.name,
async: true,
};
} else {
return resolvedModuleData;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const Module = require('module');

module.exports = function register() {
const MODULE_REFERENCE = Symbol.for('react.module.reference');
const PROMISE_PROTOTYPE = Promise.prototype;

const proxyHandlers = {
get: function(target, name, receiver) {
switch (name) {
Expand All @@ -26,6 +28,8 @@ module.exports = function register() {
return target.filepath;
case 'name':
return target.name;
case 'async':
return target.async;
// We need to special case this because createElement reads it if we pass this
// reference.
case 'defaultProps':
Expand All @@ -39,19 +43,49 @@ module.exports = function register() {
// This a placeholder value that tells the client to conditionally use the
// whole object or just the default export.
name: '',
async: target.async,
};
return true;
case 'then':
if (!target.async) {
// If this module is expected to return a Promise (such as an AsyncModule) then
// we should resolve that with a client reference that unwraps the Promise on
// the client.
const then = function then(resolve, reject) {
const moduleReference: {[string]: any} = {
$$typeof: MODULE_REFERENCE,
filepath: target.filepath,
name: '*', // Represents the whole object instead of a particular import.
async: true,
};
return Promise.resolve(
resolve(new Proxy(moduleReference, proxyHandlers)),
);
};
// If this is not used as a Promise but is treated as a reference to a `.then`
// export then we should treat it as a reference to that name.
then.$$typeof = MODULE_REFERENCE;
then.filepath = target.filepath;
// then.name is conveniently already "then" which is the export name we need.
// This will break if it's minified though.
return then;
}
}
let cachedReference = target[name];
if (!cachedReference) {
cachedReference = target[name] = {
$$typeof: MODULE_REFERENCE,
filepath: target.filepath,
name: name,
async: target.async,
};
}
return cachedReference;
},
getPrototypeOf(target) {
// Pretend to be a Promise in case anyone asks.
return PROMISE_PROTOTYPE;
},
set: function() {
throw new Error('Cannot assign to a client module from a server module.');
},
Expand All @@ -63,6 +97,7 @@ module.exports = function register() {
$$typeof: MODULE_REFERENCE,
filepath: moduleId,
name: '*', // Represents the whole object instead of a particular import.
async: false,
};
module.exports = new Proxy(moduleReference, proxyHandlers);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,83 @@ describe('ReactFlightDOM', () => {
expect(container.innerHTML).toBe('<p>@div</p>');
});

it('should unwrap async module references', async () => {
const AsyncModule = Promise.resolve(function AsyncModule({text}) {
return 'Async: ' + text;
});

const AsyncModule2 = Promise.resolve({
exportName: 'Module',
});

function Print({response}) {
return <p>{response.readRoot()}</p>;
}

function App({response}) {
return (
<Suspense fallback={<h1>Loading...</h1>}>
<Print response={response} />
</Suspense>
);
}

const AsyncModuleRef = await clientExports(AsyncModule);
const AsyncModuleRef2 = await clientExports(AsyncModule2);

const {writable, readable} = getTestStream();
const {pipe} = ReactServerDOMWriter.renderToPipeableStream(
<AsyncModuleRef text={AsyncModuleRef2.exportName} />,
webpackMap,
);
pipe(writable);
const response = ReactServerDOMReader.createFromReadableStream(readable);

const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App response={response} />);
});
expect(container.innerHTML).toBe('<p>Async: Module</p>');
});

it('should be able to import a name called "then"', async () => {
const thenExports = {
then: function then() {
return 'and then';
},
};

function Print({response}) {
return <p>{response.readRoot()}</p>;
}

function App({response}) {
return (
<Suspense fallback={<h1>Loading...</h1>}>
<Print response={response} />
</Suspense>
);
}

const ThenRef = clientExports(thenExports).then;

const {writable, readable} = getTestStream();
const {pipe} = ReactServerDOMWriter.renderToPipeableStream(
<ThenRef />,
webpackMap,
);
pipe(writable);
const response = ReactServerDOMReader.createFromReadableStream(readable);

const container = document.createElement('div');
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App response={response} />);
});
expect(container.innerHTML).toBe('<p>and then</p>');
});

it('should progressively reveal server components', async () => {
let reportedErrors = [];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ exports.clientExports = function clientExports(moduleExports) {
name: '*',
},
};
if (typeof moduleExports.then === 'function') {
moduleExports.then(asyncModuleExports => {
for (const name in asyncModuleExports) {
webpackMap[path] = {
[name]: {
id: idx,
chunks: [],
name: name,
},
};
}
});
}
for (const name in moduleExports) {
webpackMap[path] = {
[name]: {
Expand Down