diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 564f4e859871f..c386d503bfe6b 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -13,6 +13,7 @@ import type { ReactComponentInfo, ReactAsyncInfo, ReactStackTrace, + ReactCallSite, } from 'shared/ReactTypes'; import type {LazyComponent} from 'react/src/ReactLazy'; @@ -59,7 +60,7 @@ import { bindToConsole, } from './ReactFlightClientConfig'; -import {registerServerReference} from './ReactFlightReplyClient'; +import {createBoundServerReference} from './ReactFlightReplyClient'; import {readTemporaryReference} from './ReactFlightTemporaryReferences'; @@ -1001,30 +1002,20 @@ function waitForReference( function createServerReferenceProxy, T>( response: Response, - metaData: {id: any, bound: null | Thenable>}, + metaData: { + id: any, + bound: null | Thenable>, + name?: string, // DEV-only + env?: string, // DEV-only + location?: ReactCallSite, // DEV-only + }, ): (...A) => Promise { - const callServer = response._callServer; - const proxy = function (): Promise { - // $FlowFixMe[method-unbinding] - const args = Array.prototype.slice.call(arguments); - const p = metaData.bound; - if (!p) { - return callServer(metaData.id, args); - } - if (p.status === INITIALIZED) { - const bound = p.value; - return callServer(metaData.id, bound.concat(args)); - } - // Since this is a fake Promise whose .then doesn't chain, we have to wrap it. - // TODO: Remove the wrapper once that's fixed. - return ((Promise.resolve(p): any): Promise>).then( - function (bound) { - return callServer(metaData.id, bound.concat(args)); - }, - ); - }; - registerServerReference(proxy, metaData, response._encodeFormAction); - return proxy; + return createBoundServerReference( + metaData, + response._callServer, + response._encodeFormAction, + __DEV__ ? response._debugFindSourceMapURL : undefined, + ); } function getOutlinedModel( diff --git a/packages/react-client/src/ReactFlightReplyClient.js b/packages/react-client/src/ReactFlightReplyClient.js index c4033a999d96d..233f51844e2a3 100644 --- a/packages/react-client/src/ReactFlightReplyClient.js +++ b/packages/react-client/src/ReactFlightReplyClient.js @@ -13,6 +13,7 @@ import type { FulfilledThenable, RejectedThenable, ReactCustomFormAction, + ReactCallSite, } from 'shared/ReactTypes'; import type {LazyComponent} from 'react/src/ReactLazy'; import type {TemporaryReferenceSet} from './ReactFlightTemporaryReferences'; @@ -1023,7 +1024,99 @@ function isSignatureEqual( } } -export function registerServerReference( +let fakeServerFunctionIdx = 0; + +function createFakeServerFunction, T>( + name: string, + filename: string, + sourceMap: null | string, + line: number, + col: number, + environmentName: string, + innerFunction: (...A) => Promise, +): (...A) => Promise { + // This creates a fake copy of a Server Module. It represents the Server Action on the server. + // We use an eval so we can source map it to the original location. + + const comment = + '/* This module is a proxy to a Server Action. Turn on Source Maps to see the server source. */'; + + if (!name) { + // An eval:ed function with no name gets the name "eval". We give it something more descriptive. + name = ''; + } + const encodedName = JSON.stringify(name); + // We generate code where both the beginning of the function and its parenthesis is at the line + // and column of the server executed code. We use a method form since that lets us name it + // anything we want and because the beginning of the function and its parenthesis is the same + // column. Because Chrome inspects the location of the parenthesis and Firefox inspects the + // location of the beginning of the function. By not using a function expression we avoid the + // ambiguity. + let code; + if (line <= 1) { + const minSize = encodedName.length + 7; + code = + 's=>({' + + encodedName + + ' '.repeat(col < minSize ? 0 : col - minSize) + + ':' + + '(...args) => s(...args)' + + '})\n' + + comment; + } else { + code = + comment + + '\n'.repeat(line - 2) + + 'server=>({' + + encodedName + + ':\n' + + ' '.repeat(col < 1 ? 0 : col - 1) + + // The function body can get printed so we make it look nice. + // This "calls the server with the arguments". + '(...args) => server(...args)' + + '})'; + } + + if (filename.startsWith('/')) { + // If the filename starts with `/` we assume that it is a file system file + // rather than relative to the current host. Since on the server fully qualified + // stack traces use the file path. + // TODO: What does this look like on Windows? + filename = 'file://' + filename; + } + + if (sourceMap) { + // We use the prefix rsc://React/ to separate these from other files listed in + // the Chrome DevTools. We need a "host name" and not just a protocol because + // otherwise the group name becomes the root folder. Ideally we don't want to + // show these at all but there's two reasons to assign a fake URL. + // 1) A printed stack trace string needs a unique URL to be able to source map it. + // 2) If source maps are disabled or fails, you should at least be able to tell + // which file it was. + code += + '\n//# sourceURL=rsc://React/' + + encodeURIComponent(environmentName) + + '/' + + filename + + '?s' + // We add an extra s here to distinguish from the fake stack frames + fakeServerFunctionIdx++; + code += '\n//# sourceMappingURL=' + sourceMap; + } else if (filename) { + code += '\n//# sourceURL=' + filename; + } + + try { + // Eval a factory and then call it to create a closure over the inner function. + // eslint-disable-next-line no-eval + return (0, eval)(code)(innerFunction)[name]; + } catch (x) { + // If eval fails, such as if in an environment that doesn't support it, + // we fallback to just returning the inner function. + return innerFunction; + } +} + +function registerServerReference( proxy: any, reference: {id: ServerReferenceId, bound: null | Thenable>}, encodeFormAction: void | EncodeFormActionCallback, @@ -1098,16 +1191,163 @@ function bind(this: Function): Function { return newFn; } +export type FindSourceMapURLCallback = ( + fileName: string, + environmentName: string, +) => null | string; + +export function createBoundServerReference, T>( + metaData: { + id: ServerReferenceId, + bound: null | Thenable>, + name?: string, // DEV-only + env?: string, // DEV-only + location?: ReactCallSite, // DEV-only + }, + callServer: CallServerCallback, + encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, // DEV-only +): (...A) => Promise { + const id = metaData.id; + const bound = metaData.bound; + let action = function (): Promise { + // $FlowFixMe[method-unbinding] + const args = Array.prototype.slice.call(arguments); + const p = bound; + if (!p) { + return callServer(id, args); + } + if (p.status === 'fulfilled') { + const boundArgs = p.value; + return callServer(id, boundArgs.concat(args)); + } + // Since this is a fake Promise whose .then doesn't chain, we have to wrap it. + // TODO: Remove the wrapper once that's fixed. + return ((Promise.resolve(p): any): Promise>).then( + function (boundArgs) { + return callServer(id, boundArgs.concat(args)); + }, + ); + }; + if (__DEV__) { + const location = metaData.location; + if (location) { + const functionName = metaData.name || ''; + const [, filename, line, col] = location; + const env = metaData.env || 'Server'; + const sourceMap = + findSourceMapURL == null ? null : findSourceMapURL(filename, env); + action = createFakeServerFunction( + functionName, + filename, + sourceMap, + line, + col, + env, + action, + ); + } + } + registerServerReference(action, {id, bound}, encodeFormAction); + return action; +} + +// This matches either of these V8 formats. +// at name (filename:0:0) +// at filename:0:0 +// at async filename:0:0 +const v8FrameRegExp = + /^ {3} at (?:(.+) \((.+):(\d+):(\d+)\)|(?:async )?(.+):(\d+):(\d+))$/; +// This matches either of these JSC/SpiderMonkey formats. +// name@filename:0:0 +// filename:0:0 +const jscSpiderMonkeyFrameRegExp = /(?:(.*)@)?(.*):(\d+):(\d+)/; + +function parseStackLocation(error: Error): null | ReactCallSite { + // This parsing is special in that we know that the calling function will always + // be a module that initializes the server action. We also need this part to work + // cross-browser so not worth a Config. It's DEV only so not super code size + // sensitive but also a non-essential feature. + let stack = error.stack; + if (stack.startsWith('Error: react-stack-top-frame\n')) { + // V8's default formatting prefixes with the error message which we + // don't want/need. + stack = stack.slice(29); + } + const endOfFirst = stack.indexOf('\n'); + let secondFrame; + if (endOfFirst !== -1) { + // Skip the first frame. + const endOfSecond = stack.indexOf('\n', endOfFirst + 1); + if (endOfSecond === -1) { + secondFrame = stack.slice(endOfFirst + 1); + } else { + secondFrame = stack.slice(endOfFirst + 1, endOfSecond); + } + } else { + secondFrame = stack; + } + + let parsed = v8FrameRegExp.exec(secondFrame); + if (!parsed) { + parsed = jscSpiderMonkeyFrameRegExp.exec(secondFrame); + if (!parsed) { + return null; + } + } + + let name = parsed[1] || ''; + if (name === '') { + name = ''; + } + let filename = parsed[2] || parsed[5] || ''; + if (filename === '') { + filename = ''; + } + const line = +(parsed[3] || parsed[6]); + const col = +(parsed[4] || parsed[7]); + + return [name, filename, line, col]; +} + export function createServerReference, T>( id: ServerReferenceId, callServer: CallServerCallback, encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, // DEV-only + functionName?: string, ): (...A) => Promise { - const proxy = function (): Promise { + let action = function (): Promise { // $FlowFixMe[method-unbinding] const args = Array.prototype.slice.call(arguments); return callServer(id, args); }; - registerServerReference(proxy, {id, bound: null}, encodeFormAction); - return proxy; + if (__DEV__) { + // Let's see if we can find a source map for the file which contained the + // server action. We extract it from the runtime so that it's resilient to + // multiple passes of compilation as long as we can find the final source map. + const location = parseStackLocation(new Error('react-stack-top-frame')); + if (location !== null) { + const [, filename, line, col] = location; + // While the environment that the Server Reference points to can be + // in any environment, what matters here is where the compiled source + // is from and that's in the currently executing environment. We hard + // code that as the value "Client" in case the findSourceMapURL helper + // needs it. + const env = 'Client'; + const sourceMap = + findSourceMapURL == null ? null : findSourceMapURL(filename, env); + action = createFakeServerFunction( + functionName || '', + filename, + sourceMap, + line, + col, + env, + action, + ); + } + } + registerServerReference(action, {id, bound: null}, encodeFormAction); + return action; } diff --git a/packages/react-server-dom-esm/src/ReactFlightESMReferences.js b/packages/react-server-dom-esm/src/ReactFlightESMReferences.js index cd3dd349157a5..86a25c7c8608a 100644 --- a/packages/react-server-dom-esm/src/ReactFlightESMReferences.js +++ b/packages/react-server-dom-esm/src/ReactFlightESMReferences.js @@ -13,6 +13,7 @@ export type ServerReference = T & { $$typeof: symbol, $$id: string, $$bound: null | Array, + $$location?: Error, }; // eslint-disable-next-line no-unused-vars @@ -68,10 +69,30 @@ export function registerServerReference( id: string, exportName: string, ): ServerReference { - return Object.defineProperties((reference: any), { - $$typeof: {value: SERVER_REFERENCE_TAG}, - $$id: {value: id + '#' + exportName, configurable: true}, - $$bound: {value: null, configurable: true}, - bind: {value: bind, configurable: true}, - }); + const $$typeof = {value: SERVER_REFERENCE_TAG}; + const $$id = { + value: id + '#' + exportName, + configurable: true, + }; + const $$bound = {value: null, configurable: true}; + return Object.defineProperties( + (reference: any), + __DEV__ + ? { + $$typeof, + $$id, + $$bound, + $$location: { + value: Error('react-stack-top-frame'), + configurable: true, + }, + bind: {value: bind, configurable: true}, + } + : { + $$typeof, + $$id, + $$bound, + bind: {value: bind, configurable: true}, + }, + ); } diff --git a/packages/react-server-dom-esm/src/server/ReactFlightServerConfigESMBundler.js b/packages/react-server-dom-esm/src/server/ReactFlightServerConfigESMBundler.js index cba5223bcf7af..5e789518b40cb 100644 --- a/packages/react-server-dom-esm/src/server/ReactFlightServerConfigESMBundler.js +++ b/packages/react-server-dom-esm/src/server/ReactFlightServerConfigESMBundler.js @@ -70,3 +70,10 @@ export function getServerReferenceBoundArguments( ): null | Array { return serverReference.$$bound; } + +export function getServerReferenceLocation( + config: ClientManifest, + serverReference: ServerReference, +): void | Error { + return serverReference.$$location; +} diff --git a/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js b/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js index ecf6a35dfa6ef..613a7e7dc862c 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js @@ -13,6 +13,7 @@ export type ServerReference = T & { $$typeof: symbol, $$id: string, $$bound: null | Array, + $$location?: Error, }; // eslint-disable-next-line no-unused-vars @@ -81,15 +82,32 @@ export function registerServerReference( id: string, exportName: null | string, ): ServerReference { - return Object.defineProperties((reference: any), { - $$typeof: {value: SERVER_REFERENCE_TAG}, - $$id: { - value: exportName === null ? id : id + '#' + exportName, - configurable: true, - }, - $$bound: {value: null, configurable: true}, - bind: {value: bind, configurable: true}, - }); + const $$typeof = {value: SERVER_REFERENCE_TAG}; + const $$id = { + value: exportName === null ? id : id + '#' + exportName, + configurable: true, + }; + const $$bound = {value: null, configurable: true}; + return Object.defineProperties( + (reference: any), + __DEV__ + ? { + $$typeof, + $$id, + $$bound, + $$location: { + value: Error('react-stack-top-frame'), + configurable: true, + }, + bind: {value: bind, configurable: true}, + } + : { + $$typeof, + $$id, + $$bound, + bind: {value: bind, configurable: true}, + }, + ); } const PROMISE_PROTOTYPE = Promise.prototype; diff --git a/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js b/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js index 2ebc5d3f10421..d8224aff341dc 100644 --- a/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js +++ b/packages/react-server-dom-turbopack/src/server/ReactFlightServerConfigTurbopackBundler.js @@ -91,3 +91,10 @@ export function getServerReferenceBoundArguments( ): null | Array { return serverReference.$$bound; } + +export function getServerReferenceLocation( + config: ClientManifest, + serverReference: ServerReference, +): void | Error { + return serverReference.$$location; +} diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js index 6d14f412063c1..025a7368213f8 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js +++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js @@ -13,6 +13,7 @@ export type ServerReference = T & { $$typeof: symbol, $$id: string, $$bound: null | Array, + $$location?: Error, }; // eslint-disable-next-line no-unused-vars @@ -89,15 +90,32 @@ export function registerServerReference( id: string, exportName: null | string, ): ServerReference { - return Object.defineProperties((reference: any), { - $$typeof: {value: SERVER_REFERENCE_TAG}, - $$id: { - value: exportName === null ? id : id + '#' + exportName, - configurable: true, - }, - $$bound: {value: null, configurable: true}, - bind: {value: bind, configurable: true}, - }); + const $$typeof = {value: SERVER_REFERENCE_TAG}; + const $$id = { + value: exportName === null ? id : id + '#' + exportName, + configurable: true, + }; + const $$bound = {value: null, configurable: true}; + return Object.defineProperties( + (reference: any), + __DEV__ + ? { + $$typeof, + $$id, + $$bound, + $$location: { + value: Error('react-stack-top-frame'), + configurable: true, + }, + bind: {value: bind, configurable: true}, + } + : { + $$typeof, + $$id, + $$bound, + bind: {value: bind, configurable: true}, + }, + ); } const PROMISE_PROTOTYPE = Promise.prototype; diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js index 7bbfea1484bed..969f9e125e8c5 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -1393,9 +1393,21 @@ describe('ReactFlightDOMBrowser', () => { const body = await ReactServerDOMClient.encodeReply(args); return callServer(ref, body); }, + undefined, + undefined, + 'upper', ), }; + expect(ServerModuleBImportedOnClient.upper.name).toBe( + __DEV__ ? 'upper' : 'action', + ); + if (__DEV__) { + expect(ServerModuleBImportedOnClient.upper.toString()).toBe( + '(...args) => server(...args)', + ); + } + function Client({action}) { // Client side pass a Server Reference into an action. actionProxy = text => action(ServerModuleBImportedOnClient.upper, text); diff --git a/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js b/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js index a0872b61fa475..d29516ff946ea 100644 --- a/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js +++ b/packages/react-server-dom-webpack/src/server/ReactFlightServerConfigWebpackBundler.js @@ -91,3 +91,10 @@ export function getServerReferenceBoundArguments( ): null | Array { return serverReference.$$bound; } + +export function getServerReferenceLocation( + config: ClientManifest, + serverReference: ServerReference, +): void | Error { + return serverReference.$$location; +} diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index db0ee7b3cebad..bfb12c31c7ffc 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -64,6 +64,7 @@ import type { ReactComponentInfo, ReactAsyncInfo, ReactStackTrace, + ReactCallSite, } from 'shared/ReactTypes'; import type {ReactElement} from 'shared/ReactElementType'; import type {LazyComponent} from 'react/src/ReactLazy'; @@ -72,6 +73,7 @@ import { resolveClientReferenceMetadata, getServerReferenceId, getServerReferenceBoundArguments, + getServerReferenceLocation, getClientReferenceKey, isClientReference, isServerReference, @@ -1955,17 +1957,47 @@ function serializeServerReference( return serializeServerReferenceID(existingId); } - const bound: null | Array = getServerReferenceBoundArguments( + const boundArgs: null | Array = getServerReferenceBoundArguments( request.bundlerConfig, serverReference, ); + const bound = boundArgs === null ? null : Promise.resolve(boundArgs); + const id = getServerReferenceId(request.bundlerConfig, serverReference); + + let location: null | ReactCallSite = null; + if (__DEV__) { + const error = getServerReferenceLocation( + request.bundlerConfig, + serverReference, + ); + if (error) { + const frames = parseStackTrace(error, 1); + if (frames.length > 0) { + location = frames[0]; + } + } + } + const serverReferenceMetadata: { id: ServerReferenceId, bound: null | Promise>, - } = { - id: getServerReferenceId(request.bundlerConfig, serverReference), - bound: bound ? Promise.resolve(bound) : null, - }; + name?: string, // DEV-only + env?: string, // DEV-only + location?: ReactCallSite, // DEV-only + } = + __DEV__ && location !== null + ? { + id, + bound, + name: + typeof serverReference === 'function' ? serverReference.name : '', + env: (0, request.environmentName)(), + location, + } + : { + id, + bound, + }; const metadataId = outlineModel(request, serverReferenceMetadata); writtenServerReferences.set(serverReference, metadataId); return serializeServerReferenceID(metadataId); diff --git a/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js b/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js index 00578a4da2459..ac3e17c630174 100644 --- a/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js +++ b/packages/react-server/src/ReactFlightServerConfigBundlerCustom.js @@ -23,3 +23,4 @@ export const resolveClientReferenceMetadata = export const getServerReferenceId = $$$config.getServerReferenceId; export const getServerReferenceBoundArguments = $$$config.getServerReferenceBoundArguments; +export const getServerReferenceLocation = $$$config.getServerReferenceLocation; diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.markup.js b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js index 12feb37ac5697..7c879de39f271 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.markup.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js @@ -90,3 +90,10 @@ export function getServerReferenceBoundArguments( 'Use a fixed URL for any forms instead.', ); } + +export function getServerReferenceLocation( + config: ClientManifest, + serverReference: ServerReference, +): void { + return undefined; +}