From ce09ace9a23b61dfba53f5a83d52b377bc642bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 27 Jan 2023 20:08:26 -0500 Subject: [PATCH] Improve Error Messages when Access Client References (#26059) This renames Module References to Client References, since they are in the server->client direction. I also changed the Proxies exposed from the `node-register` loader to provide better error messages. Ideally, some of this should be replicated in the ESM loader too but neither are the source of truth. We'll replicate this in the static form in the Next.js loaders. cc @huozhi @shuding - All references are now functions so that when you call them on the server, we can yield a better error message. - References that are themselves already referring to an export name are now proxies that error when you dot into them. - `use(...)` can now be used on a client reference to unwrap it server side and then pass a reference to the awaited value. --- .../react-client/src/ReactFlightClient.js | 12 +- .../src/__tests__/ReactFlight-test.js | 55 +++-- .../ReactFlightClientHostConfig.custom.js | 4 +- .../src/ReactNoopFlightClient.js | 2 +- .../src/ReactNoopFlightServer.js | 6 +- .../ReactFlightDOMRelayClientHostConfig.js | 10 +- .../ReactFlightDOMRelayServerHostConfig.js | 12 +- .../ReactFlightDOMRelayClientIntegration.js | 2 +- .../ReactFlightClientWebpackBundlerConfig.js | 10 +- .../ReactFlightServerWebpackBundlerConfig.js | 20 +- .../src/ReactFlightWebpackNodeLoader.js | 32 ++- .../src/ReactFlightWebpackNodeRegister.js | 227 ++++++++++++++---- .../src/__tests__/ReactFlightDOM-test.js | 68 ++++++ .../src/__tests__/utils/WebpackMock.js | 20 +- .../ReactFlightNativeRelayClientHostConfig.js | 10 +- .../ReactFlightNativeRelayServerHostConfig.js | 12 +- ...ReactFlightNativeRelayClientIntegration.js | 2 +- packages/react-server/src/ReactFlightHooks.js | 22 +- .../react-server/src/ReactFlightServer.js | 28 +-- .../ReactFlightServerBundlerConfigCustom.js | 8 +- packages/shared/isValidElementType.js | 4 +- scripts/flow/react-relay-hooks.js | 4 +- scripts/jest/setupHostConfigs.js | 4 +- 23 files changed, 415 insertions(+), 159 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 70e9f5ddaab95..5293bc794af26 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -11,7 +11,7 @@ import type {Thenable} from 'shared/ReactTypes'; import type {LazyComponent} from 'react/src/ReactLazy'; import type { - ModuleReference, + ClientReference, ModuleMetaData, UninitializedModel, Response, @@ -19,7 +19,7 @@ import type { } from './ReactFlightClientHostConfig'; import { - resolveModuleReference, + resolveClientReference, preloadModule, requireModule, parseModel, @@ -67,7 +67,7 @@ type ResolvedModelChunk = { }; type ResolvedModuleChunk = { status: 'resolved_module', - value: ModuleReference, + value: ClientReference, reason: null, _response: Response, then(resolve: (T) => mixed, reject: (mixed) => mixed): void, @@ -262,7 +262,7 @@ function createResolvedModelChunk( function createResolvedModuleChunk( response: Response, - value: ModuleReference, + value: ClientReference, ): ResolvedModuleChunk { // $FlowFixMe Flow doesn't support functions as constructors return new Chunk(RESOLVED_MODULE, value, null, response); @@ -293,7 +293,7 @@ function resolveModelChunk( function resolveModuleChunk( chunk: SomeChunk, - value: ModuleReference, + value: ClientReference, ): void { if (chunk.status !== PENDING && chunk.status !== BLOCKED) { // We already resolved. We didn't expect to see this. @@ -589,7 +589,7 @@ export function resolveModule( const chunks = response._chunks; const chunk = chunks.get(id); const moduleMetaData: ModuleMetaData = parseModel(response, model); - const moduleReference = resolveModuleReference( + const moduleReference = resolveClientReference( response._bundlerConfig, moduleMetaData, ); diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 6760531f75839..416ad45bc671b 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -91,11 +91,16 @@ describe('ReactFlight', () => { }; }); - function moduleReference(value) { - return { - $$typeof: Symbol.for('react.module.reference'), - value: value, - }; + function clientReference(value) { + return Object.defineProperties( + function() { + throw new Error('Cannot call a client function from the server.'); + }, + { + $$typeof: {value: Symbol.for('react.client.reference')}, + value: {value: value}, + }, + ); } it('can render a Server Component', async () => { @@ -136,7 +141,7 @@ describe('ReactFlight', () => { ); } - const User = moduleReference(UserClient); + const User = clientReference(UserClient); function Greeting({firstName, lastName}) { return ; @@ -327,7 +332,7 @@ describe('ReactFlight', () => { return
I am client
; } - const ClientComponentReference = moduleReference(ClientComponent); + const ClientComponentReference = clientReference(ClientComponent); let load = null; const loadClientComponentReference = () => { @@ -369,7 +374,7 @@ describe('ReactFlight', () => { function ClientImpl({children}) { return children; } - const Client = moduleReference(ClientImpl); + const Client = clientReference(ClientImpl); function EventHandlerProp() { return ( @@ -488,7 +493,7 @@ describe('ReactFlight', () => { ); } - const ClientComponentReference = moduleReference(ClientComponent); + const ClientComponentReference = clientReference(ClientComponent); function Server() { return ( @@ -576,7 +581,7 @@ describe('ReactFlight', () => { function ClientImpl({value}) { return
{value}
; } - const Client = moduleReference(ClientImpl); + const Client = clientReference(ClientImpl); expect(() => { const transport = ReactNoopFlightServer.render( , @@ -593,7 +598,7 @@ describe('ReactFlight', () => { function ClientImpl({children}) { return
{children}
; } - const Client = moduleReference(ClientImpl); + const Client = clientReference(ClientImpl); expect(() => { const transport = ReactNoopFlightServer.render( Current date: {new Date()}, @@ -612,7 +617,7 @@ describe('ReactFlight', () => { function ClientImpl({value}) { return
{value}
; } - const Client = moduleReference(ClientImpl); + const Client = clientReference(ClientImpl); expect(() => { const transport = ReactNoopFlightServer.render(); ReactNoopFlightClient.read(transport); @@ -629,7 +634,7 @@ describe('ReactFlight', () => { function ClientImpl({value}) { return
{value}
; } - const Client = moduleReference(ClientImpl); + const Client = clientReference(ClientImpl); expect(() => { const transport = ReactNoopFlightServer.render( , @@ -646,7 +651,7 @@ describe('ReactFlight', () => { function ClientImpl({value}) { return
{value}
; } - const Client = moduleReference(ClientImpl); + const Client = clientReference(ClientImpl); expect(() => { const transport = ReactNoopFlightServer.render( hi}} />, @@ -665,7 +670,7 @@ describe('ReactFlight', () => { function ClientImpl({value}) { return
{value}
; } - const Client = moduleReference(ClientImpl); + const Client = clientReference(ClientImpl); expect(() => { const transport = ReactNoopFlightServer.render( { ); }); + it('should warn in DEV if a a client reference is passed to useContext()', () => { + const Context = React.createContext(); + const ClientContext = clientReference(Context); + function ServerComponent() { + return React.useContext(ClientContext); + } + expect(() => { + const transport = ReactNoopFlightServer.render(); + ReactNoopFlightClient.read(transport); + }).toErrorDev('Cannot read a Client Context from a Server Component.', { + withoutStack: true, + }); + }); + describe('Hooks', () => { function DivWithId({children}) { const id = React.useId(); @@ -776,7 +795,7 @@ describe('ReactFlight', () => { ); } - const ClientDoublerModuleRef = moduleReference(ClientDoubler); + const ClientDoublerModuleRef = clientReference(ClientDoubler); const transport = ReactNoopFlightServer.render(); expect(Scheduler).toHaveYielded([]); @@ -1000,7 +1019,7 @@ describe('ReactFlight', () => { return {context}; } - const Bar = moduleReference(ClientBar); + const Bar = clientReference(ClientBar); function Foo() { return ( @@ -1077,7 +1096,7 @@ describe('ReactFlight', () => { return
{value}
; } - const Baz = moduleReference(ClientBaz); + const Baz = clientReference(ClientBaz); function Bar() { return ( diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js index 76262bce219de..c34bb7e25bde5 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js @@ -28,8 +28,8 @@ declare var $$$hostConfig: any; export type Response = any; export opaque type BundlerConfig = mixed; export opaque type ModuleMetaData = mixed; -export opaque type ModuleReference = mixed; // eslint-disable-line no-unused-vars -export const resolveModuleReference = $$$hostConfig.resolveModuleReference; +export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars +export const resolveClientReference = $$$hostConfig.resolveClientReference; export const preloadModule = $$$hostConfig.preloadModule; export const requireModule = $$$hostConfig.requireModule; diff --git a/packages/react-noop-renderer/src/ReactNoopFlightClient.js b/packages/react-noop-renderer/src/ReactNoopFlightClient.js index f3517ae7d1f8d..5cb6e20e532f3 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightClient.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightClient.js @@ -22,7 +22,7 @@ type Source = Array; const {createResponse, processStringChunk, getRoot, close} = ReactFlightClient({ supportsBinaryStreams: false, - resolveModuleReference(bundlerConfig: null, idx: string) { + resolveClientReference(bundlerConfig: null, idx: string) { return idx; }, preloadModule(idx: string) {}, diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index 586512bc963f4..d50025b87efb0 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -48,10 +48,10 @@ const ReactNoopFlightServer = ReactFlightServer({ clonePrecomputedChunk(chunk: string): string { return chunk; }, - isModuleReference(reference: Object): boolean { - return reference.$$typeof === Symbol.for('react.module.reference'); + isClientReference(reference: Object): boolean { + return reference.$$typeof === Symbol.for('react.client.reference'); }, - getModuleKey(reference: Object): Object { + getClientReferenceKey(reference: Object): Object { return reference; }, resolveModuleMetaData( diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js index a4dccf3513a35..a5f2b243f70cd 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js @@ -13,7 +13,7 @@ import type {JSResourceReference} from 'JSResourceReference'; import type {ModuleMetaData} from 'ReactFlightDOMRelayClientIntegration'; -export type ModuleReference = JSResourceReference; +export type ClientReference = JSResourceReference; import { parseModelString, @@ -25,7 +25,7 @@ export { requireModule, } from 'ReactFlightDOMRelayClientIntegration'; -import {resolveModuleReference as resolveModuleReferenceImpl} from 'ReactFlightDOMRelayClientIntegration'; +import {resolveClientReference as resolveClientReferenceImpl} from 'ReactFlightDOMRelayClientIntegration'; import isArray from 'shared/isArray'; @@ -37,11 +37,11 @@ export type UninitializedModel = JSONValue; export type Response = ResponseBase; -export function resolveModuleReference( +export function resolveClientReference( bundlerConfig: BundlerConfig, moduleData: ModuleMetaData, -): ModuleReference { - return resolveModuleReferenceImpl(moduleData); +): ClientReference { + return resolveClientReferenceImpl(moduleData); } // $FlowFixMe[missing-local-annot] diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js index 50d31ea1316ca..27fbaf25f64ad 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js @@ -17,7 +17,7 @@ import JSResourceReferenceImpl from 'JSResourceReferenceImpl'; import hasOwnProperty from 'shared/hasOwnProperty'; import isArray from 'shared/isArray'; -export type ModuleReference = JSResourceReference; +export type ClientReference = JSResourceReference; import type { Destination, @@ -39,13 +39,15 @@ export type { ModuleMetaData, } from 'ReactFlightDOMRelayServerIntegration'; -export function isModuleReference(reference: Object): boolean { +export function isClientReference(reference: Object): boolean { return reference instanceof JSResourceReferenceImpl; } -export type ModuleKey = ModuleReference; +export type ClientReferenceKey = ClientReference; -export function getModuleKey(reference: ModuleReference): ModuleKey { +export function getClientReferenceKey( + reference: ClientReference, +): ClientReferenceKey { // We use the reference object itself as the key because we assume the // object will be cached by the bundler runtime. return reference; @@ -53,7 +55,7 @@ export function getModuleKey(reference: ModuleReference): ModuleKey { export function resolveModuleMetaData( config: BundlerConfig, - resource: ModuleReference, + resource: ClientReference, ): ModuleMetaData { return resolveModuleMetaDataImpl(config, resource); } diff --git a/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js b/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js index ede723236670a..50cc6f38221b3 100644 --- a/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js +++ b/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js @@ -10,7 +10,7 @@ import JSResourceReferenceImpl from 'JSResourceReferenceImpl'; const ReactFlightDOMRelayClientIntegration = { - resolveModuleReference(moduleData) { + resolveClientReference(moduleData) { return new JSResourceReferenceImpl(moduleData); }, preloadModule(moduleReference) {}, diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js b/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js index abb472f744d21..f4307c8832377 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js @@ -29,12 +29,12 @@ export opaque type ModuleMetaData = { }; // eslint-disable-next-line no-unused-vars -export opaque type ModuleReference = ModuleMetaData; +export opaque type ClientReference = ModuleMetaData; -export function resolveModuleReference( +export function resolveClientReference( bundlerConfig: BundlerConfig, moduleData: ModuleMetaData, -): ModuleReference { +): ClientReference { if (bundlerConfig) { const resolvedModuleData = bundlerConfig[moduleData.id][moduleData.name]; if (moduleData.async) { @@ -64,7 +64,7 @@ function ignoreReject() { // Start preloading the modules since we might need them soon. // This function doesn't suspend. export function preloadModule( - moduleData: ModuleReference, + moduleData: ClientReference, ): null | Thenable { const chunks = moduleData.chunks; const promises = []; @@ -117,7 +117,7 @@ export function preloadModule( // Actually require the module or suspend if it's not yet ready. // Increase priority if necessary. -export function requireModule(moduleData: ModuleReference): T { +export function requireModule(moduleData: ClientReference): T { let moduleExports; if (moduleData.async) { // We assume that preloadModule has been called before, which diff --git a/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js b/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js index 8951a3cce88c3..c662d6d51f243 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js +++ b/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js @@ -16,7 +16,7 @@ type WebpackMap = { export type BundlerConfig = WebpackMap; // eslint-disable-next-line no-unused-vars -export type ModuleReference = { +export type ClientReference = { $$typeof: symbol, filepath: string, name: string, @@ -30,11 +30,13 @@ export type ModuleMetaData = { async: boolean, }; -export type ModuleKey = string; +export type ClientReferenceKey = string; -const MODULE_TAG = Symbol.for('react.module.reference'); +const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference'); -export function getModuleKey(reference: ModuleReference): ModuleKey { +export function getClientReferenceKey( + reference: ClientReference, +): ClientReferenceKey { return ( reference.filepath + '#' + @@ -43,17 +45,17 @@ export function getModuleKey(reference: ModuleReference): ModuleKey { ); } -export function isModuleReference(reference: Object): boolean { - return reference.$$typeof === MODULE_TAG; +export function isClientReference(reference: Object): boolean { + return reference.$$typeof === CLIENT_REFERENCE_TAG; } export function resolveModuleMetaData( config: BundlerConfig, - moduleReference: ModuleReference, + clientReference: ClientReference, ): ModuleMetaData { const resolvedModuleData = - config[moduleReference.filepath][moduleReference.name]; - if (moduleReference.async) { + config[clientReference.filepath][clientReference.name]; + if (clientReference.async) { return { id: resolvedModuleData.id, chunks: resolvedModuleData.chunks, diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js index 8f2f5539292aa..bdbcb73a2a5e3 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js +++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js @@ -246,19 +246,39 @@ export async function transformSource( ); let newSrc = - "const MODULE_REFERENCE = Symbol.for('react.module.reference');\n"; + "const CLIENT_REFERENCE = Symbol.for('react.client.reference');\n"; for (let i = 0; i < names.length; i++) { const name = names[i]; if (name === 'default') { newSrc += 'export default '; + newSrc += 'Object.defineProperties(function() {'; + newSrc += + 'throw new Error(' + + JSON.stringify( + `Attempted to call the default export of ${context.url} from the server` + + `but it's on the client. It's not possible to invoke a client function from ` + + `the server, it can only be rendered as a Component or passed to props of a` + + `Client Component.`, + ) + + ');'; } else { newSrc += 'export const ' + name + ' = '; + newSrc += 'export default '; + newSrc += 'Object.defineProperties(function() {'; + newSrc += + 'throw new Error(' + + JSON.stringify( + `Attempted to call ${name}() from the server but ${name} is on the client. ` + + `It's not possible to invoke a client function from the server, it can ` + + `only be rendered as a Component or passed to props of a Client Component.`, + ) + + ');'; } - newSrc += '{ $$typeof: MODULE_REFERENCE, filepath: '; - newSrc += JSON.stringify(context.url); - newSrc += ', name: '; - newSrc += JSON.stringify(name); - newSrc += '};\n'; + newSrc += '},{'; + newSrc += 'name: { value: ' + JSON.stringify(name) + '},'; + newSrc += '$$typeof: {value: CLIENT_REFERENCE},'; + newSrc += 'filepath: {value: ' + JSON.stringify(context.url) + '}'; + newSrc += '});\n'; } return {source: newSrc}; diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js index 13fb79d78b8ca..c03b1687c73c5 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js +++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js @@ -12,15 +12,69 @@ const url = require('url'); const Module = require('module'); module.exports = function register() { - const MODULE_REFERENCE = Symbol.for('react.module.reference'); + const CLIENT_REFERENCE = Symbol.for('react.client.reference'); const PROMISE_PROTOTYPE = Promise.prototype; + const deepProxyHandlers = { + get: function(target: Function, name: string, receiver: Proxy) { + 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; + case 'async': + return target.async; + // We need to special case this because createElement reads it if we pass this + // reference. + case 'defaultProps': + return undefined; + case 'getDefaultProps': + return undefined; + // Avoid this attempting to be serialized. + case 'toJSON': + return undefined; + case Symbol.toPrimitive: + // $FlowFixMe[prop-missing] + return Object.prototype[Symbol.toPrimitive]; + case 'Provider': + throw new Error( + `Cannot render a Client Context Provider on the Server. ` + + `Instead, you can export a Client Component wrapper ` + + `that itself renders a Client Context Provider.`, + ); + } + let expression; + switch (target.name) { + case '': + // eslint-disable-next-line react-internal/safe-string-coercion + expression = String(name); + break; + case '*': + // eslint-disable-next-line react-internal/safe-string-coercion + expression = String(name); + break; + default: + // eslint-disable-next-line react-internal/safe-string-coercion + expression = String(target.name) + '.' + String(name); + } + throw new Error( + `Cannot access ${expression} on the server. ` + + 'You cannot dot into a client module from a server component. ' + + 'You can only pass the imported name through.', + ); + }, + set: function() { + throw new Error('Cannot assign to a client module from a server module.'); + }, + }; + const proxyHandlers = { - get: function( - target: {[string]: $FlowFixMe}, - name: string, - receiver: Proxy<{[string]: $FlowFixMe}>, - ) { + get: function(target: Function, name: string, receiver: Proxy) { switch (name) { // These names are read by the Flight runtime if you end up using the exports object. case '$$typeof': @@ -37,57 +91,125 @@ module.exports = function register() { // reference. case 'defaultProps': return undefined; + case 'getDefaultProps': + return undefined; + // Avoid this attempting to be serialized. + case 'toJSON': + return undefined; + case Symbol.toPrimitive: + // $FlowFixMe[prop-missing] + return Object.prototype[Symbol.toPrimitive]; 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: '', - async: target.async, - }; + const moduleId = target.filepath; + target.default = Object.defineProperties( + (function() { + throw new Error( + `Attempted to call the default export of ${moduleId} from the server` + + `but it's on the client. It's not possible to invoke a client function from ` + + `the server, it can only be rendered as a Component or passed to props of a` + + `Client Component.`, + ); + }: any), + { + // This a placeholder value that tells the client to conditionally use the + // whole object or just the default export. + name: {value: ''}, + $$typeof: {value: CLIENT_REFERENCE}, + filepath: {value: target.filepath}, + async: {value: target.async}, + }, + ); return true; case 'then': + if (target.then) { + // Use a cached value + return target.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 innerModuleId = target.filepath; + const clientReference: Function = Object.defineProperties( + (function() { + throw new Error( + `Attempted to call the module exports of ${innerModuleId} from the server` + + `but it's on the client. It's not possible to invoke a client function from ` + + `the server, it can only be rendered as a Component or passed to props of a` + + `Client Component.`, + ); + }: any), + { + // Represents the whole object instead of a particular import. + name: {value: '*'}, + $$typeof: {value: CLIENT_REFERENCE}, + filepath: {value: target.filepath}, + async: {value: true}, + }, + ); + const proxy = new Proxy(clientReference, proxyHandlers); + + // Treat this as a resolved Promise for React's use() + target.status = 'fulfilled'; + target.value = proxy; + // $FlowFixMe[missing-local-annot] - const then = function then(resolve, reject: any) { - 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( - // $FlowFixMe[incompatible-call] found when upgrading Flow - 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. + const then = (target.then = Object.defineProperties( + (function then(resolve, reject: any) { + // Expose to React. + return Promise.resolve( + // $FlowFixMe[incompatible-call] found when upgrading Flow + resolve(proxy), + ); + }: any), + // 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. + { + name: {value: 'then'}, + $$typeof: {value: CLIENT_REFERENCE}, + filepath: {value: target.filepath}, + async: {value: false}, + }, + )); return then; + } else { + // Since typeof .then === 'function' is a feature test we'd continue recursing + // indefinitely if we return a function. Instead, we return an object reference + // if we check further. + return undefined; } } let cachedReference = target[name]; if (!cachedReference) { - cachedReference = target[name] = { - $$typeof: MODULE_REFERENCE, - filepath: target.filepath, - name: name, - async: target.async, - }; + const reference = Object.defineProperties( + (function() { + throw new Error( + // eslint-disable-next-line react-internal/safe-string-coercion + `Attempted to call ${String(name)}() from the server but ${String( + name, + )} is on the client. ` + + `It's not possible to invoke a client function from the server, it can ` + + `only be rendered as a Component or passed to props of a Client Component.`, + ); + }: any), + { + name: {value: name}, + $$typeof: {value: CLIENT_REFERENCE}, + filepath: {value: target.filepath}, + async: {value: target.async}, + }, + ); + cachedReference = target[name] = new Proxy( + reference, + deepProxyHandlers, + ); } return cachedReference; }, - getPrototypeOf(target: {[string]: $FlowFixMe}) { + getPrototypeOf(target: Function): Object { // Pretend to be a Promise in case anyone asks. return PROMISE_PROTOTYPE; }, @@ -98,15 +220,26 @@ module.exports = function register() { // $FlowFixMe[prop-missing] found when upgrading Flow Module._extensions['.client.js'] = function(module, path) { - 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. - async: false, - }; + const moduleId: string = (url.pathToFileURL(path).href: any); + const clientReference: Function = Object.defineProperties( + (function() { + throw new Error( + `Attempted to call the module exports of ${moduleId} from the server` + + `but it's on the client. It's not possible to invoke a client function from ` + + `the server, it can only be rendered as a Component or passed to props of a` + + `Client Component.`, + ); + }: any), + { + // Represents the whole object instead of a particular import. + name: {value: '*'}, + $$typeof: {value: CLIENT_REFERENCE}, + filepath: {value: moduleId}, + async: {value: false}, + }, + ); // $FlowFixMe[incompatible-call] found when upgrading Flow - module.exports = new Proxy(moduleReference, proxyHandlers); + module.exports = new Proxy(clientReference, proxyHandlers); }; // $FlowFixMe[prop-missing] found when upgrading Flow diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index 524227cacd763..83d4b5ffbdba4 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -286,6 +286,45 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

Async: Module

'); }); + // @gate enableUseHook + it('should unwrap async module references using use', async () => { + const AsyncModule = Promise.resolve('Async Text'); + + function Print({response}) { + return use(response); + } + + function App({response}) { + return ( + Loading...}> + + + ); + } + + const AsyncModuleRef = clientExports(AsyncModule); + + function ServerComponent() { + const text = use(AsyncModuleRef); + return

{text}

; + } + + const {writable, readable} = getTestStream(); + const {pipe} = ReactServerDOMWriter.renderToPipeableStream( + , + webpackMap, + ); + pipe(writable); + const response = ReactServerDOMReader.createFromReadableStream(readable); + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.innerHTML).toBe('

Async Text

'); + }); + // @gate enableUseHook it('should be able to import a name called "then"', async () => { const thenExports = { @@ -324,6 +363,35 @@ describe('ReactFlightDOM', () => { expect(container.innerHTML).toBe('

and then

'); }); + it('throws when accessing a member below the client exports', () => { + const ClientModule = clientExports({ + Component: {deep: 'thing'}, + }); + function dotting() { + return ClientModule.Component.deep; + } + expect(dotting).toThrowError( + 'Cannot access Component.deep on the server. ' + + 'You cannot dot into a client module from a server component. ' + + 'You can only pass the imported name through.', + ); + }); + + it('throws when accessing a Context.Provider below the client exports', () => { + const Context = React.createContext(); + const ClientModule = clientExports({ + Context, + }); + function dotting() { + return ClientModule.Context.Provider; + } + expect(dotting).toThrowError( + `Cannot render a Client Context Provider on the Server. ` + + `Instead, you can export a Client Component wrapper ` + + `that itself renders a Client Context Provider.`, + ); + }); + // @gate enableUseHook it('should progressively reveal server components', async () => { let reportedErrors = []; diff --git a/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js b/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js index 82a65409e5074..5e33802448988 100644 --- a/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js +++ b/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js @@ -81,12 +81,10 @@ exports.clientExports = function clientExports(moduleExports) { moduleExports.then( asyncModuleExports => { for (const name in asyncModuleExports) { - webpackMap[path] = { - [name]: { - id: idx, - chunks: [], - name: name, - }, + webpackMap[path][name] = { + id: idx, + chunks: [], + name: name, }; } }, @@ -94,12 +92,10 @@ exports.clientExports = function clientExports(moduleExports) { ); } for (const name in moduleExports) { - webpackMap[path] = { - [name]: { - id: idx, - chunks: [], - name: name, - }, + webpackMap[path][name] = { + id: idx, + chunks: [], + name: name, }; } const mod = {exports: {}}; diff --git a/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js b/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js index ae2e2c0afb028..981b351f18433 100644 --- a/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js +++ b/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js @@ -13,7 +13,7 @@ import type {JSResourceReference} from 'JSResourceReference'; import type {ModuleMetaData} from 'ReactFlightNativeRelayClientIntegration'; -export type ModuleReference = JSResourceReference; +export type ClientReference = JSResourceReference; import { parseModelString, @@ -25,7 +25,7 @@ export { requireModule, } from 'ReactFlightNativeRelayClientIntegration'; -import {resolveModuleReference as resolveModuleReferenceImpl} from 'ReactFlightNativeRelayClientIntegration'; +import {resolveClientReference as resolveClientReferenceImpl} from 'ReactFlightNativeRelayClientIntegration'; import isArray from 'shared/isArray'; @@ -37,11 +37,11 @@ export type UninitializedModel = JSONValue; export type Response = ResponseBase; -export function resolveModuleReference( +export function resolveClientReference( bundlerConfig: BundlerConfig, moduleData: ModuleMetaData, -): ModuleReference { - return resolveModuleReferenceImpl(moduleData); +): ClientReference { + return resolveClientReferenceImpl(moduleData); } // $FlowFixMe[missing-local-annot] diff --git a/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js b/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js index 814773b3f128a..552b37913819b 100644 --- a/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js +++ b/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js @@ -14,7 +14,7 @@ import isArray from 'shared/isArray'; import type {JSResourceReference} from 'JSResourceReference'; import JSResourceReferenceImpl from 'JSResourceReferenceImpl'; -export type ModuleReference = JSResourceReference; +export type ClientReference = JSResourceReference; import type { Destination, @@ -36,13 +36,15 @@ export type { ModuleMetaData, } from 'ReactFlightNativeRelayServerIntegration'; -export function isModuleReference(reference: Object): boolean { +export function isClientReference(reference: Object): boolean { return reference instanceof JSResourceReferenceImpl; } -export type ModuleKey = ModuleReference; +export type ClientReferenceKey = ClientReference; -export function getModuleKey(reference: ModuleReference): ModuleKey { +export function getClientReferenceKey( + reference: ClientReference, +): ClientReferenceKey { // We use the reference object itself as the key because we assume the // object will be cached by the bundler runtime. return reference; @@ -50,7 +52,7 @@ export function getModuleKey(reference: ModuleReference): ModuleKey { export function resolveModuleMetaData( config: BundlerConfig, - resource: ModuleReference, + resource: ClientReference, ): ModuleMetaData { return resolveModuleMetaDataImpl(config, resource); } diff --git a/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayClientIntegration.js b/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayClientIntegration.js index 2d258288359ba..ec0f44c840b36 100644 --- a/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayClientIntegration.js +++ b/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayClientIntegration.js @@ -10,7 +10,7 @@ import JSResourceReferenceImpl from 'JSResourceReferenceImpl'; const ReactFlightNativeRelayClientIntegration = { - resolveModuleReference(moduleData) { + resolveClientReference(moduleData) { return new JSResourceReferenceImpl(moduleData); }, preloadModule(moduleReference) {}, diff --git a/packages/react-server/src/ReactFlightHooks.js b/packages/react-server/src/ReactFlightHooks.js index d1fb683260620..a6f13d6900fce 100644 --- a/packages/react-server/src/ReactFlightHooks.js +++ b/packages/react-server/src/ReactFlightHooks.js @@ -18,6 +18,7 @@ import { import {readContext as readContextImpl} from './ReactFlightNewContext'; import {enableUseHook} from 'shared/ReactFeatureFlags'; import {createThenableState, trackUsedThenable} from './ReactFlightThenable'; +import {isClientReference} from './ReactFlightServerConfig'; let currentRequest = null; let thenableIndexCounter = 0; @@ -47,9 +48,13 @@ export function getThenableStateAfterSuspending(): null | ThenableState { function readContext(context: ReactServerContext): T { if (__DEV__) { if (context.$$typeof !== REACT_SERVER_CONTEXT_TYPE) { - console.error( - 'Only createServerContext is supported in Server Components.', - ); + if (isClientReference(context)) { + console.error('Cannot read a Client Context from a Server Component.'); + } else { + console.error( + 'Only createServerContext is supported in Server Components.', + ); + } } if (currentRequest === null) { console.error( @@ -118,7 +123,10 @@ function useId(): string { } function use(usable: Usable): T { - if (usable !== null && typeof usable === 'object') { + if ( + (usable !== null && typeof usable === 'object') || + typeof usable === 'function' + ) { // $FlowFixMe[method-unbinding] if (typeof usable.then === 'function') { // This is a thenable. @@ -138,6 +146,12 @@ function use(usable: Usable): T { } } + if (__DEV__) { + if (isClientReference(usable)) { + console.error('Cannot use() an already resolved Client Reference.'); + } + } + // eslint-disable-next-line react-internal/safe-string-coercion throw new Error('An unsupported type was passed to use(): ' + String(usable)); } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 548a7f5252d28..1d1b32f8ca49f 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -12,8 +12,8 @@ import type { Chunk, BundlerConfig, ModuleMetaData, - ModuleReference, - ModuleKey, + ClientReference, + ClientReferenceKey, } from './ReactFlightServerConfig'; import type {ContextSnapshot} from './ReactFlightNewContext'; import type {ThenableState} from './ReactFlightThenable'; @@ -44,8 +44,8 @@ import { processErrorChunkDev, processReferenceChunk, resolveModuleMetaData, - getModuleKey, - isModuleReference, + getClientReferenceKey, + isClientReference, supportsRequestStorage, requestStorage, } from './ReactFlightServerConfig'; @@ -135,7 +135,7 @@ export type Request = { completedJSONChunks: Array, completedErrorChunks: Array, writtenSymbols: Map, - writtenModules: Map, + writtenModules: Map, writtenProviders: Map, identifierPrefix: string, identifierCount: number, @@ -293,7 +293,7 @@ function attemptResolveElement( } } if (typeof type === 'function') { - if (isModuleReference(type)) { + if (isClientReference(type)) { // This is a reference to a Client Component. return [REACT_ELEMENT_TYPE, type, key, props]; } @@ -323,7 +323,7 @@ function attemptResolveElement( // Any built-in works as long as its props are serializable. return [REACT_ELEMENT_TYPE, type, key, props]; } else if (type != null && typeof type === 'object') { - if (isModuleReference(type)) { + if (isClientReference(type)) { // This is a reference to a Client Component. return [REACT_ELEMENT_TYPE, type, key, props]; } @@ -420,13 +420,13 @@ function serializeByRefID(id: number): string { return '@' + id.toString(16); } -function serializeModuleReference( +function serializeClientReference( request: Request, parent: {+[key: string | number]: ReactModel} | $ReadOnlyArray, key: string, - moduleReference: ModuleReference, + moduleReference: ClientReference, ): string { - const moduleKey: ModuleKey = getModuleKey(moduleReference); + const moduleKey: ClientReferenceKey = getClientReferenceKey(moduleReference); const writtenModules = request.writtenModules; const existingId = writtenModules.get(moduleKey); if (existingId !== undefined) { @@ -891,8 +891,8 @@ export function resolveModelToJSON( } if (typeof value === 'object') { - if (isModuleReference(value)) { - return serializeModuleReference(request, parent, key, (value: any)); + if (isClientReference(value)) { + return serializeClientReference(request, parent, key, (value: any)); } else if ((value: any).$$typeof === REACT_PROVIDER_TYPE) { const providerKey = ((value: any): ReactProviderType)._context ._globalName; @@ -961,8 +961,8 @@ export function resolveModelToJSON( } if (typeof value === 'function') { - if (isModuleReference(value)) { - return serializeModuleReference(request, parent, key, (value: any)); + if (isClientReference(value)) { + return serializeClientReference(request, parent, key, (value: any)); } if (/^on[A-Z]/.test(key)) { throw new Error( diff --git a/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js b/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js index 7024968c16d9c..0d2f84dbecc27 100644 --- a/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js +++ b/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js @@ -10,9 +10,9 @@ declare var $$$hostConfig: any; export opaque type BundlerConfig = mixed; -export opaque type ModuleReference = mixed; // eslint-disable-line no-unused-vars +export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars export opaque type ModuleMetaData: any = mixed; -export opaque type ModuleKey: any = mixed; -export const isModuleReference = $$$hostConfig.isModuleReference; -export const getModuleKey = $$$hostConfig.getModuleKey; +export opaque type ClientReferenceKey: any = mixed; +export const isClientReference = $$$hostConfig.isClientReference; +export const getClientReferenceKey = $$$hostConfig.getClientReferenceKey; export const resolveModuleMetaData = $$$hostConfig.resolveModuleMetaData; diff --git a/packages/shared/isValidElementType.js b/packages/shared/isValidElementType.js index a7715592602a6..5b8511e084dba 100644 --- a/packages/shared/isValidElementType.js +++ b/packages/shared/isValidElementType.js @@ -33,7 +33,7 @@ import { enableLegacyHidden, } from './ReactFeatureFlags'; -const REACT_MODULE_REFERENCE: symbol = Symbol.for('react.module.reference'); +const REACT_CLIENT_REFERENCE: symbol = Symbol.for('react.client.reference'); export default function isValidElementType(type: mixed): boolean { if (typeof type === 'string' || typeof type === 'function') { @@ -68,7 +68,7 @@ export default function isValidElementType(type: mixed): boolean { // types supported by any Flight configuration anywhere since // we don't know which Flight build this will end up being used // with. - type.$$typeof === REACT_MODULE_REFERENCE || + type.$$typeof === REACT_CLIENT_REFERENCE || type.getModuleId !== undefined ) { return true; diff --git a/scripts/flow/react-relay-hooks.js b/scripts/flow/react-relay-hooks.js index 36673f1e89026..4b52e1d857564 100644 --- a/scripts/flow/react-relay-hooks.js +++ b/scripts/flow/react-relay-hooks.js @@ -57,7 +57,7 @@ declare module 'ReactFlightDOMRelayClientIntegration' { import type {JSResourceReference} from 'JSResourceReference'; declare export opaque type ModuleMetaData; - declare export function resolveModuleReference( + declare export function resolveClientReference( moduleData: ModuleMetaData, ): JSResourceReference; declare export function preloadModule( @@ -90,7 +90,7 @@ declare module 'ReactFlightNativeRelayClientIntegration' { import type {JSResourceReference} from 'JSResourceReference'; declare export opaque type ModuleMetaData; - declare export function resolveModuleReference( + declare export function resolveClientReference( moduleData: ModuleMetaData, ): JSResourceReference; declare export function preloadModule( diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js index cd763c57ff6f2..495b1126fc733 100644 --- a/scripts/jest/setupHostConfigs.js +++ b/scripts/jest/setupHostConfigs.js @@ -82,8 +82,8 @@ jest.mock('react-server/flight', () => { jest.mock(shimServerStreamConfigPath, () => config); jest.mock(shimServerFormatConfigPath, () => config); jest.mock('react-server/src/ReactFlightServerBundlerConfigCustom', () => ({ - isModuleReference: config.isModuleReference, - getModuleKey: config.getModuleKey, + isClientReference: config.isClientReference, + getClientReferenceKey: config.getClientReferenceKey, resolveModuleMetaData: config.resolveModuleMetaData, })); jest.mock(shimFlightServerConfigPath, () =>