diff --git a/fixtures/flight/loader/index.js b/fixtures/flight/loader/index.js index b9cfa5b73eee0..04960e6dcc9e1 100644 --- a/fixtures/flight/loader/index.js +++ b/fixtures/flight/loader/index.js @@ -1,4 +1,8 @@ -import {resolve, getSource} from 'react-transport-dom-webpack/node-loader'; +import { + resolve, + getSource, + transformSource as reactTransformSource, +} from 'react-transport-dom-webpack/node-loader'; export {resolve, getSource}; @@ -13,7 +17,7 @@ const babelOptions = { ], }; -export async function transformSource(source, context, defaultTransformSource) { +async function babelTransformSource(source, context, defaultTransformSource) { const {format} = context; if (format === 'module') { const opt = Object.assign({filename: context.url}, babelOptions); @@ -22,3 +26,9 @@ export async function transformSource(source, context, defaultTransformSource) { } return defaultTransformSource(source, context, defaultTransformSource); } + +export async function transformSource(source, context, defaultTransformSource) { + return reactTransformSource(source, context, (s, c) => { + return babelTransformSource(s, c, defaultTransformSource); + }); +} diff --git a/fixtures/flight/server/handler.server.js b/fixtures/flight/server/handler.server.js index d7715af9cc757..86476bb2c2d71 100644 --- a/fixtures/flight/server/handler.server.js +++ b/fixtures/flight/server/handler.server.js @@ -17,14 +17,35 @@ module.exports = async function(req, res) { pipeToNodeWritable(, res, { // TODO: Read from a map on the disk. [resolve('../src/Counter.client.js')]: { - id: './src/Counter.client.js', - chunks: ['1'], - name: 'default', + Counter: { + id: './src/Counter.client.js', + chunks: ['2'], + name: 'Counter', + }, + }, + [resolve('../src/Counter2.client.js')]: { + Counter: { + id: './src/Counter2.client.js', + chunks: ['1'], + name: 'Counter', + }, }, [resolve('../src/ShowMore.client.js')]: { - id: './src/ShowMore.client.js', - chunks: ['2'], - name: 'default', + default: { + id: './src/ShowMore.client.js', + chunks: ['3'], + name: 'default', + }, + '': { + id: './src/ShowMore.client.js', + chunks: ['3'], + name: '', + }, + '*': { + id: './src/ShowMore.client.js', + chunks: ['3'], + name: '*', + }, }, }); }; diff --git a/fixtures/flight/src/App.server.js b/fixtures/flight/src/App.server.js index 54a644dc48d49..35a223dce8b07 100644 --- a/fixtures/flight/src/App.server.js +++ b/fixtures/flight/src/App.server.js @@ -2,7 +2,8 @@ import * as React from 'react'; import Container from './Container.js'; -import Counter from './Counter.client.js'; +import {Counter} from './Counter.client.js'; +import {Counter as Counter2} from './Counter2.client.js'; import ShowMore from './ShowMore.client.js'; @@ -11,6 +12,7 @@ export default function App() {

Hello, world

+

Lorem ipsum

diff --git a/fixtures/flight/src/Counter.client.js b/fixtures/flight/src/Counter.client.js index 00a1f2cbe440d..676280f0542f6 100644 --- a/fixtures/flight/src/Counter.client.js +++ b/fixtures/flight/src/Counter.client.js @@ -2,7 +2,7 @@ import * as React from 'react'; import Container from './Container.js'; -export default function Counter() { +export function Counter() { const [count, setCount] = React.useState(0); return ( diff --git a/fixtures/flight/src/Counter2.client.js b/fixtures/flight/src/Counter2.client.js new file mode 100644 index 0000000000000..084f7bc5f071d --- /dev/null +++ b/fixtures/flight/src/Counter2.client.js @@ -0,0 +1 @@ +export * from './Counter.client.js'; diff --git a/packages/react-transport-dom-webpack/package.json b/packages/react-transport-dom-webpack/package.json index a71a558f5160f..8e2db02610eff 100644 --- a/packages/react-transport-dom-webpack/package.json +++ b/packages/react-transport-dom-webpack/package.json @@ -50,6 +50,7 @@ "webpack": "^4.43.0" }, "dependencies": { + "acorn": "^6.2.1", "loose-envify": "^1.1.0", "object-assign": "^4.1.1" }, diff --git a/packages/react-transport-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js b/packages/react-transport-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js index 3e667960d2620..f3c4e1bf1c16d 100644 --- a/packages/react-transport-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js +++ b/packages/react-transport-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js @@ -59,5 +59,16 @@ export function requireModule(moduleData: ModuleReference): T { throw entry; } } - return __webpack_require__(moduleData.id)[moduleData.name]; + 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. + return moduleExports; + } + if (moduleData.name === '') { + // This is a placeholder value that represents that the caller accessed the + // default property of this if it was an ESM interop module. + return moduleExports.__esModule ? moduleExports.default : moduleExports; + } + return moduleExports[moduleData.name]; } diff --git a/packages/react-transport-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js b/packages/react-transport-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js index f691809522ca1..c8469eeba8068 100644 --- a/packages/react-transport-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js +++ b/packages/react-transport-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js @@ -8,7 +8,9 @@ */ type WebpackMap = { - [filename: string]: ModuleMetaData, + [filepath: string]: { + [name: string]: ModuleMetaData, + }, }; export type BundlerConfig = WebpackMap; @@ -16,6 +18,7 @@ export type BundlerConfig = WebpackMap; // eslint-disable-next-line no-unused-vars export type ModuleReference = { $$typeof: Symbol, + filepath: string, name: string, }; @@ -30,7 +33,7 @@ export type ModuleKey = string; const MODULE_TAG = Symbol.for('react.module.reference'); export function getModuleKey(reference: ModuleReference): ModuleKey { - return reference.name; + return reference.filepath + '#' + reference.name; } export function isModuleReference(reference: Object): boolean { @@ -41,5 +44,5 @@ export function resolveModuleMetaData( config: BundlerConfig, moduleReference: ModuleReference, ): ModuleMetaData { - return config[moduleReference.name]; + return config[moduleReference.filepath][moduleReference.name]; } diff --git a/packages/react-transport-dom-webpack/src/ReactFlightWebpackNodeLoader.js b/packages/react-transport-dom-webpack/src/ReactFlightWebpackNodeLoader.js index 102da33028765..d716cda4653cc 100644 --- a/packages/react-transport-dom-webpack/src/ReactFlightWebpackNodeLoader.js +++ b/packages/react-transport-dom-webpack/src/ReactFlightWebpackNodeLoader.js @@ -7,6 +7,8 @@ * @flow */ +import acorn from 'acorn'; + type ResolveContext = { conditions: Array, parentURL: string | void, @@ -16,11 +18,10 @@ type ResolveFunction = ( string, ResolveContext, ResolveFunction, -) => Promise; +) => Promise<{url: string}>; type GetSourceContext = { format: string, - url: string, }; type GetSourceFunction = ( @@ -29,15 +30,32 @@ type GetSourceFunction = ( GetSourceFunction, ) => Promise<{source: Source}>; +type TransformSourceContext = { + format: string, + url: string, +}; + +type TransformSourceFunction = ( + Source, + TransformSourceContext, + TransformSourceFunction, +) => Promise<{source: Source}>; + type Source = string | ArrayBuffer | Uint8Array; let warnedAboutConditionsFlag = false; +let stashedGetSource: null | GetSourceFunction = null; +let stashedResolve: null | ResolveFunction = null; + export async function resolve( specifier: string, context: ResolveContext, defaultResolve: ResolveFunction, -): Promise { +): Promise<{url: string}> { + // We stash this in case we end up needing to resolve export * statements later. + stashedResolve = defaultResolve; + if (!context.conditions.includes('react-server')) { context = { ...context, @@ -71,14 +89,174 @@ export async function getSource( url: string, context: GetSourceContext, defaultGetSource: GetSourceFunction, +) { + // We stash this in case we end up needing to resolve export * statements later. + stashedGetSource = defaultGetSource; + return defaultGetSource(url, context, defaultGetSource); +} + +function addExportNames(names, node) { + switch (node.type) { + case 'Identifier': + names.push(node.name); + return; + case 'ObjectPattern': + for (let i = 0; i < node.properties.length; i++) + addExportNames(names, node.properties[i]); + return; + case 'ArrayPattern': + for (let i = 0; i < node.elements.length; i++) { + const element = node.elements[i]; + if (element) addExportNames(names, element); + } + return; + case 'Property': + addExportNames(names, node.value); + return; + case 'AssignmentPattern': + addExportNames(names, node.left); + return; + case 'RestElement': + addExportNames(names, node.argument); + return; + case 'ParenthesizedExpression': + addExportNames(names, node.expression); + return; + } +} + +function resolveClientImport( + specifier: string, + parentURL: string, +): Promise<{url: string}> { + // Resolve an import specifier as if it was loaded by the client. This doesn't use + // the overrides that this loader does but instead reverts to the default. + // This resolution algorithm will not necessarily have the same configuration + // as the actual client loader. It should mostly work and if it doesn't you can + // always convert to explicit exported names instead. + const conditions = ['node', 'import']; + if (stashedResolve === null) { + throw new Error( + 'Expected resolve to have been called before transformSource', + ); + } + return stashedResolve(specifier, {conditions, parentURL}, stashedResolve); +} + +async function loadClientImport( + url: string, + defaultTransformSource: TransformSourceFunction, ): Promise<{source: Source}> { - if (url.endsWith('.client.js')) { - // TODO: Named exports. - const src = - "export default { $$typeof: Symbol.for('react.module.reference'), name: " + - JSON.stringify(url) + - '}'; - return {source: src}; + if (stashedGetSource === null) { + throw new Error( + 'Expected getSource to have been called before transformSource', + ); } - return defaultGetSource(url, context, defaultGetSource); + // TODO: Validate that this is another module by calling getFormat. + const {source} = await stashedGetSource( + url, + {format: 'module'}, + stashedGetSource, + ); + return defaultTransformSource( + source, + {format: 'module', url}, + defaultTransformSource, + ); +} + +async function parseExportNamesInto( + transformedSource: string, + names: Array, + parentURL: string, + defaultTransformSource, +): Promise { + const {body} = acorn.parse(transformedSource, { + ecmaVersion: '2019', + sourceType: 'module', + }); + for (let i = 0; i < body.length; i++) { + const node = body[i]; + switch (node.type) { + case 'ExportAllDeclaration': + if (node.exported) { + addExportNames(names, node.exported); + continue; + } else { + const {url} = await resolveClientImport(node.source.value, parentURL); + const {source} = await loadClientImport(url, defaultTransformSource); + if (typeof source !== 'string') { + throw new Error('Expected the transformed source to be a string.'); + } + parseExportNamesInto(source, names, url, defaultTransformSource); + continue; + } + case 'ExportDefaultDeclaration': + names.push('default'); + continue; + case 'ExportNamedDeclaration': + if (node.declaration) { + if (node.declaration.type === 'VariableDeclaration') { + const declarations = node.declaration.declarations; + for (let j = 0; j < declarations.length; j++) { + addExportNames(names, declarations[j].id); + } + } else { + addExportNames(names, node.declaration.id); + } + } + if (node.specificers) { + const specificers = node.specificers; + for (let j = 0; j < specificers.length; j++) { + addExportNames(names, specificers[j].exported); + } + } + continue; + } + } +} + +export async function transformSource( + source: Source, + context: TransformSourceContext, + defaultTransformSource: TransformSourceFunction, +): Promise<{source: Source}> { + const transformed = await defaultTransformSource( + source, + context, + defaultTransformSource, + ); + if (context.format === 'module' && context.url.endsWith('.client.js')) { + const transformedSource = transformed.source; + if (typeof transformedSource !== 'string') { + throw new Error('Expected source to have been transformed to a string.'); + } + + const names = []; + await parseExportNamesInto( + transformedSource, + names, + context.url, + defaultTransformSource, + ); + + let newSrc = + "const MODULE_REFERENCE = Symbol.for('react.module.reference');\n"; + for (let i = 0; i < names.length; i++) { + const name = names[i]; + if (name === 'default') { + newSrc += 'export default '; + } else { + newSrc += 'export const ' + name + ' = '; + } + newSrc += '{ $$typeof: MODULE_REFERENCE, filepath: '; + newSrc += JSON.stringify(context.url); + newSrc += ', name: '; + newSrc += JSON.stringify(name); + newSrc += '};\n'; + } + + return {source: newSrc}; + } + return transformed; } diff --git a/packages/react-transport-dom-webpack/src/ReactFlightWebpackNodeRegister.js b/packages/react-transport-dom-webpack/src/ReactFlightWebpackNodeRegister.js index 17c3b8ef9d9c6..26a92b8323d31 100644 --- a/packages/react-transport-dom-webpack/src/ReactFlightWebpackNodeRegister.js +++ b/packages/react-transport-dom-webpack/src/ReactFlightWebpackNodeRegister.js @@ -13,11 +13,58 @@ const url = require('url'); const Module = require('module'); module.exports = function register() { + const MODULE_REFERENCE = Symbol.for('react.module.reference'); + const proxyHandlers = { + get: function(target, name, receiver) { + switch (name) { + // These names are read by the Flight runtime if you end up using the exports object. + case '$$typeof': + // These names are a little too common. We should probably have a way to + // have the Flight runtime extract the inner target instead. + return target.$$typeof; + case 'filepath': + return target.filepath; + case 'name': + return target.name; + // We need to special case this because createElement reads it if we pass this + // reference. + case 'defaultProps': + return undefined; + case '__esModule': + // Something is conditionally checking which export to use. We'll pretend to be + // an ESM compat module but then we'll check again on the client. + target.default = { + $$typeof: MODULE_REFERENCE, + filepath: target.filepath, + // This a placeholder value that tells the client to conditionally use the + // whole object or just the default export. + name: '', + }; + return true; + } + let cachedReference = target[name]; + if (!cachedReference) { + cachedReference = target[name] = { + $$typeof: MODULE_REFERENCE, + filepath: target.filepath, + name: name, + }; + } + return cachedReference; + }, + set: function() { + throw new Error('Cannot assign to a client module from a server module.'); + }, + }; + (require: any).extensions['.client.js'] = function(module, path) { - module.exports = { - $$typeof: Symbol.for('react.module.reference'), - name: url.pathToFileURL(path).href, + const moduleId = url.pathToFileURL(path).href; + const moduleReference: {[string]: any} = { + $$typeof: MODULE_REFERENCE, + filepath: moduleId, + name: '*', // Represents the whole object instead of a particular import. }; + module.exports = new Proxy(moduleReference, proxyHandlers); }; const originalResolveFilename = Module._resolveFilename; diff --git a/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js index d8568c661c2af..e437c7b20bc61 100644 --- a/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-transport-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -68,12 +68,14 @@ describe('ReactFlightDOM', () => { d: moduleExport, }; webpackMap['path/' + idx] = { - id: '' + idx, - chunks: [], - name: 'd', + default: { + id: '' + idx, + chunks: [], + name: 'd', + }, }; const MODULE_TAG = Symbol.for('react.module.reference'); - return {$$typeof: MODULE_TAG, name: 'path/' + idx}; + return {$$typeof: MODULE_TAG, filepath: 'path/' + idx, name: 'default'}; } async function waitForSuspense(fn) { diff --git a/scripts/flow/environment.js b/scripts/flow/environment.js index 8fd7111ebf521..3efe41ff344d2 100644 --- a/scripts/flow/environment.js +++ b/scripts/flow/environment.js @@ -68,4 +68,4 @@ declare module 'EventListener' { } declare function __webpack_chunk_load__(id: string): Promise; -declare function __webpack_require__(id: string): {default: any}; +declare function __webpack_require__(id: string): any; diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 641e6dd260271..6f0f88e04e00b 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -301,7 +301,7 @@ const bundles = [ moduleType: RENDERER_UTILS, entry: 'react-transport-dom-webpack/node-loader', global: 'ReactFlightWebpackNodeLoader', - externals: [], + externals: ['acorn'], }, /******* React Transport DOM Webpack Node.js CommonJS Loader *******/ diff --git a/yarn.lock b/yarn.lock index d44e85f4e47d7..7b6cb08909628 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2617,15 +2617,10 @@ acorn-globals@^6.0.0: acorn "^7.1.1" acorn-walk "^7.1.1" -acorn-jsx@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.1.0.tgz#294adb71b57398b0680015f0a38c563ee1db5384" - integrity sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw== - -acorn-jsx@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.2.0.tgz#4c66069173d6fdd68ed85239fc256226182b2ebe" - integrity sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ== +acorn-jsx@^5.0.0, acorn-jsx@^5.2.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b" + integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng== acorn-walk@^6.0.1: version "6.2.0" @@ -2637,25 +2632,15 @@ acorn-walk@^7.1.1: resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc" integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA== -acorn@^6.0.1, acorn@^6.0.7: - version "6.3.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.3.0.tgz#0087509119ffa4fc0a0041d1e93a417e68cb856e" - integrity sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA== - -acorn@^6.4.1: - version "6.4.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474" - integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA== - -acorn@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c" - integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ== +acorn@^6.0.1, acorn@^6.0.7, acorn@^6.2.1, acorn@^6.4.1: + version "6.4.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6" + integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ== -acorn@^7.1.1, acorn@^7.4.0: - version "7.4.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.0.tgz#e1ad486e6c54501634c6c397c5c121daa383607c" - integrity sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w== +acorn@^7.1.0, acorn@^7.1.1, acorn@^7.4.0: + version "7.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== adbkit-logcat@^1.1.0: version "1.1.0"