diff --git a/fixtures/flight/package.json b/fixtures/flight/package.json index 680c1a0055220..9e0906f0b3ea1 100644 --- a/fixtures/flight/package.json +++ b/fixtures/flight/package.json @@ -16,6 +16,7 @@ "babel-plugin-named-asset-import": "^0.3.8", "babel-preset-react-app": "^10.0.1", "bfj": "^7.0.2", + "body-parser": "^1.20.1", "browserslist": "^4.18.1", "camelcase": "^6.2.1", "case-sensitive-paths-webpack-plugin": "^2.4.0", diff --git a/fixtures/flight/server/cli.js b/fixtures/flight/server/cli.js index 0705d824dfc08..dea40db72ee59 100644 --- a/fixtures/flight/server/cli.js +++ b/fixtures/flight/server/cli.js @@ -25,6 +25,7 @@ babelRegister({ }); const express = require('express'); +const bodyParser = require('body-parser'); const app = express(); // Application @@ -32,6 +33,17 @@ app.get('/', function (req, res) { require('./handler.js')(req, res); }); +app.options('/', function (req, res) { + res.setHeader('Allow', 'Allow: GET,HEAD,POST'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'rsc-action'); + res.end(); +}); + +app.post('/', bodyParser.text(), function (req, res) { + require('./handler.js')(req, res); +}); + app.get('/todos', function (req, res) { res.setHeader('Access-Control-Allow-Origin', '*'); res.json([ diff --git a/fixtures/flight/server/handler.js b/fixtures/flight/server/handler.js index 5b6f6b2abea8b..549424afaf525 100644 --- a/fixtures/flight/server/handler.js +++ b/fixtures/flight/server/handler.js @@ -1,31 +1,49 @@ 'use strict'; const {renderToPipeableStream} = require('react-server-dom-webpack/server'); -const {readFile} = require('fs'); +const {readFile} = require('fs').promises; const {resolve} = require('path'); const React = require('react'); -module.exports = function (req, res) { - // const m = require('../src/App.js'); - import('../src/App.js').then(m => { - const dist = process.env.NODE_ENV === 'development' ? 'dist' : 'build'; - readFile( - resolve(__dirname, `../${dist}/react-client-manifest.json`), - 'utf8', - (err, data) => { - if (err) { - throw err; - } - - const App = m.default.default || m.default; - res.setHeader('Access-Control-Allow-Origin', '*'); - const moduleMap = JSON.parse(data); - const {pipe} = renderToPipeableStream( - React.createElement(App), - moduleMap - ); - pipe(res); +module.exports = async function (req, res) { + switch (req.method) { + case 'POST': { + const serverReference = JSON.parse(req.get('rsc-action')); + const {filepath, name} = serverReference; + const action = (await import(filepath))[name]; + // Validate that this is actually a function we intended to expose and + // not the client trying to invoke arbitrary functions. In a real app, + // you'd have a manifest verifying this before even importing it. + if (action.$$typeof !== Symbol.for('react.server.reference')) { + throw new Error('Invalid action'); } - ); - }); + + const args = JSON.parse(req.body); + const result = action.apply(null, args); + + res.setHeader('Access-Control-Allow-Origin', '*'); + const {pipe} = renderToPipeableStream(result, {}); + pipe(res); + + return; + } + default: { + // const m = require('../src/App.js'); + const m = await import('../src/App.js'); + const dist = process.env.NODE_ENV === 'development' ? 'dist' : 'build'; + const data = await readFile( + resolve(__dirname, `../${dist}/react-client-manifest.json`), + 'utf8' + ); + const App = m.default.default || m.default; + res.setHeader('Access-Control-Allow-Origin', '*'); + const moduleMap = JSON.parse(data); + const {pipe} = renderToPipeableStream( + React.createElement(App), + moduleMap + ); + pipe(res); + return; + } + } }; diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index 0f86f7edad3cd..91583dd43e79a 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -6,6 +6,9 @@ import {Counter} from './Counter.js'; import {Counter as Counter2} from './Counter2.js'; import ShowMore from './ShowMore.js'; +import Button from './Button.js'; + +import {like} from './actions.js'; export default async function App() { const res = await fetch('http://localhost:3001/todos'); @@ -23,6 +26,9 @@ export default async function App() {

Lorem ipsum

+
+ +
); } diff --git a/fixtures/flight/src/Button.js b/fixtures/flight/src/Button.js new file mode 100644 index 0000000000000..811fb8da76ffb --- /dev/null +++ b/fixtures/flight/src/Button.js @@ -0,0 +1,15 @@ +'use client'; + +import * as React from 'react'; + +export default function Button({action, children}) { + return ( + + ); +} diff --git a/fixtures/flight/src/actions.js b/fixtures/flight/src/actions.js new file mode 100644 index 0000000000000..6534908e01ed6 --- /dev/null +++ b/fixtures/flight/src/actions.js @@ -0,0 +1,6 @@ +'use server'; + +export async function like() { + console.log('Like'); + return 'Liked'; +} diff --git a/fixtures/flight/src/index.js b/fixtures/flight/src/index.js index 71c87a2ea17bc..8b052b327b067 100644 --- a/fixtures/flight/src/index.js +++ b/fixtures/flight/src/index.js @@ -3,7 +3,22 @@ import {Suspense} from 'react'; import ReactDOM from 'react-dom/client'; import ReactServerDOMReader from 'react-server-dom-webpack/client'; -let data = ReactServerDOMReader.createFromFetch(fetch('http://localhost:3001')); +let data = ReactServerDOMReader.createFromFetch( + fetch('http://localhost:3001'), + { + callServer(id, args) { + const response = fetch('http://localhost:3001', { + method: 'POST', + cors: 'cors', + headers: { + 'rsc-action': JSON.stringify({filepath: id.id, name: id.name}), + }, + body: JSON.stringify(args), + }); + return ReactServerDOMReader.createFromFetch(response); + }, + } +); function Content() { return React.use(data); diff --git a/fixtures/flight/yarn.lock b/fixtures/flight/yarn.lock index afacff324cb2c..687cfafec5297 100644 --- a/fixtures/flight/yarn.lock +++ b/fixtures/flight/yarn.lock @@ -3221,6 +3221,24 @@ body-parser@1.20.0: type-is "~1.6.18" unpipe "1.0.0" +body-parser@^1.20.1: + version "1.20.1" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" + integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== + dependencies: + bytes "3.1.2" + content-type "~1.0.4" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.1" + type-is "~1.6.18" + unpipe "1.0.0" + bonjour-service@^1.0.11: version "1.0.13" resolved "https://registry.yarnpkg.com/bonjour-service/-/bonjour-service-1.0.13.tgz#4ac003dc1626023252d58adf2946f57e5da450c1" @@ -7970,6 +7988,13 @@ qs@6.10.3: dependencies: side-channel "^1.0.4" +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + quick-lru@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 615ab130dff35..e187e5af9c212 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -12,7 +12,7 @@ import type {LazyComponent} from 'react/src/ReactLazy'; import type { ClientReference, - ModuleMetaData, + ClientReferenceMetadata, UninitializedModel, Response, BundlerConfig, @@ -29,6 +29,8 @@ import {REACT_LAZY_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols'; import {getOrCreateServerContext} from 'shared/ReactServerContextRegistry'; +export type CallServerCallback = (id: any, args: A) => Promise; + export type JSONValue = | number | null @@ -148,6 +150,7 @@ Chunk.prototype.then = function ( export type ResponseBase = { _bundlerConfig: BundlerConfig, + _callServer: CallServerCallback, _chunks: Map>, ... }; @@ -468,6 +471,28 @@ function createModelReject(chunk: SomeChunk): (error: mixed) => void { return (error: mixed) => triggerErrorOnChunk(chunk, error); } +function createServerReferenceProxy, T>( + response: Response, + metaData: any, +): (...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.status === INITIALIZED) { + const bound = p.value; + return callServer(metaData, 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).then(function (bound) { + return callServer(metaData, bound.concat(args)); + }); + }; + return proxy; +} + export function parseModelString( response: Response, parentObject: Object, @@ -499,11 +524,33 @@ export function parseModelString( return chunk; } case 'S': { + // Symbol return Symbol.for(value.substring(2)); } case 'P': { + // Server Context Provider return getOrCreateServerContext(value.substring(2)).Provider; } + case 'F': { + // Server Reference + const id = parseInt(value.substring(2), 16); + const chunk = getChunk(response, id); + switch (chunk.status) { + case RESOLVED_MODEL: + initializeModelChunk(chunk); + break; + } + // The status might have changed after initialization. + switch (chunk.status) { + case INITIALIZED: { + const metadata = chunk.value; + return createServerReferenceProxy(response, metadata); + } + // We always encode it first in the stream so it won't be pending. + default: + throw chunk.reason; + } + } default: { // We assume that anything else is a reference ID. const id = parseInt(value.substring(1), 16); @@ -551,10 +598,21 @@ export function parseModelTuple( return value; } -export function createResponse(bundlerConfig: BundlerConfig): ResponseBase { +function missingCall() { + throw new Error( + 'Trying to call a function from "use server" but the callServer option ' + + 'was not implemented in your router runtime.', + ); +} + +export function createResponse( + bundlerConfig: BundlerConfig, + callServer: void | CallServerCallback, +): ResponseBase { const chunks: Map> = new Map(); const response = { _bundlerConfig: bundlerConfig, + _callServer: callServer !== undefined ? callServer : missingCall, _chunks: chunks, }; return response; @@ -581,16 +639,19 @@ export function resolveModule( ): void { const chunks = response._chunks; const chunk = chunks.get(id); - const moduleMetaData: ModuleMetaData = parseModel(response, model); - const moduleReference = resolveClientReference<$FlowFixMe>( + const clientReferenceMetadata: ClientReferenceMetadata = parseModel( + response, + model, + ); + const clientReference = resolveClientReference<$FlowFixMe>( response._bundlerConfig, - moduleMetaData, + clientReferenceMetadata, ); // TODO: Add an option to encode modules that are lazy loaded. // For now we preload all modules as early as possible since it's likely // that we'll need them. - const promise = preloadModule(moduleReference); + const promise = preloadModule(clientReference); if (promise) { let blockedChunk: BlockedChunk; if (!chunk) { @@ -605,16 +666,16 @@ export function resolveModule( blockedChunk.status = BLOCKED; } promise.then( - () => resolveModuleChunk(blockedChunk, moduleReference), + () => resolveModuleChunk(blockedChunk, clientReference), error => triggerErrorOnChunk(blockedChunk, error), ); } else { if (!chunk) { - chunks.set(id, createResolvedModuleChunk(response, moduleReference)); + chunks.set(id, createResolvedModuleChunk(response, clientReference)); } else { // This can't actually happen because we don't have any forward // references to modules. - resolveModuleChunk(chunk, moduleReference); + resolveModuleChunk(chunk, clientReference); } } } diff --git a/packages/react-client/src/ReactFlightClientStream.js b/packages/react-client/src/ReactFlightClientStream.js index 57887e48d4550..16178c5d8075e 100644 --- a/packages/react-client/src/ReactFlightClientStream.js +++ b/packages/react-client/src/ReactFlightClientStream.js @@ -7,8 +7,8 @@ * @flow */ +import type {CallServerCallback} from './ReactFlightClient'; import type {Response} from './ReactFlightClientHostConfigStream'; - import type {BundlerConfig} from './ReactFlightClientHostConfig'; import { @@ -120,11 +120,14 @@ function createFromJSONCallback(response: Response) { }; } -export function createResponse(bundlerConfig: BundlerConfig): Response { +export function createResponse( + bundlerConfig: BundlerConfig, + callServer: void | CallServerCallback, +): Response { // NOTE: CHECK THE COMPILER OUTPUT EACH TIME YOU CHANGE THIS. // It should be inlined to one object literal but minor changes can break it. const stringDecoder = supportsBinaryStreams ? createStringDecoder() : null; - const response: any = createResponseBase(bundlerConfig); + const response: any = createResponseBase(bundlerConfig, callServer); response._partialRow = ''; if (supportsBinaryStreams) { response._stringDecoder = stringDecoder; diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index 56d4deb3fb39f..02d95592a12a5 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -447,7 +447,10 @@ describe('ReactFlight', () => { - + @@ -459,7 +462,10 @@ describe('ReactFlight', () => { - + diff --git a/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js b/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js index c34bb7e25bde5..8262bd7f165d3 100644 --- a/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js +++ b/packages/react-client/src/forks/ReactFlightClientHostConfig.custom.js @@ -27,7 +27,7 @@ declare var $$$hostConfig: any; export type Response = any; export opaque type BundlerConfig = mixed; -export opaque type ModuleMetaData = mixed; +export opaque type ClientReferenceMetadata = mixed; export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars export const resolveClientReference = $$$hostConfig.resolveClientReference; export const preloadModule = $$$hostConfig.preloadModule; diff --git a/packages/react-noop-renderer/src/ReactNoopFlightServer.js b/packages/react-noop-renderer/src/ReactNoopFlightServer.js index d50025b87efb0..da0ec35a8b026 100644 --- a/packages/react-noop-renderer/src/ReactNoopFlightServer.js +++ b/packages/react-noop-renderer/src/ReactNoopFlightServer.js @@ -51,10 +51,13 @@ const ReactNoopFlightServer = ReactFlightServer({ isClientReference(reference: Object): boolean { return reference.$$typeof === Symbol.for('react.client.reference'); }, + isServerReference(reference: Object): boolean { + return reference.$$typeof === Symbol.for('react.server.reference'); + }, getClientReferenceKey(reference: Object): Object { return reference; }, - resolveModuleMetaData( + resolveClientReferenceMetadata( config: void, reference: {$$typeof: symbol, value: any}, ) { diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js index d17838bf3d70d..81b954687423b 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayClientHostConfig.js @@ -11,7 +11,7 @@ import type {JSONValue, ResponseBase} from 'react-client/src/ReactFlightClient'; import type {JSResourceReference} from 'JSResourceReference'; -import type {ModuleMetaData} from 'ReactFlightDOMRelayClientIntegration'; +import type {ClientReferenceMetadata} from 'ReactFlightDOMRelayClientIntegration'; export type ClientReference = JSResourceReference; @@ -29,7 +29,7 @@ import {resolveClientReference as resolveClientReferenceImpl} from 'ReactFlightD import isArray from 'shared/isArray'; -export type {ModuleMetaData} from 'ReactFlightDOMRelayClientIntegration'; +export type {ClientReferenceMetadata} from 'ReactFlightDOMRelayClientIntegration'; export type BundlerConfig = null; @@ -39,9 +39,9 @@ export type Response = ResponseBase; export function resolveClientReference( bundlerConfig: BundlerConfig, - moduleData: ModuleMetaData, + metadata: ClientReferenceMetadata, ): ClientReference { - return resolveClientReferenceImpl(moduleData); + return resolveClientReferenceImpl(metadata); } function parseModelRecursively( diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js index f770432dce1e2..73b793f0d715c 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayProtocol.js @@ -7,7 +7,7 @@ * @flow */ -import type {ModuleMetaData} from 'ReactFlightDOMRelayServerIntegration'; +import type {ClientReferenceMetadata} from 'ReactFlightDOMRelayServerIntegration'; export type JSONValue = | string @@ -19,7 +19,7 @@ export type JSONValue = export type RowEncoding = | ['O', number, JSONValue] - | ['I', number, ModuleMetaData] + | ['I', number, ClientReferenceMetadata] | ['P', number, string] | ['S', number, string] | [ diff --git a/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js b/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js index bf6572110d4d1..b0f41e156dd26 100644 --- a/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js +++ b/packages/react-server-dom-relay/src/ReactFlightDOMRelayServerHostConfig.js @@ -18,31 +18,37 @@ import hasOwnProperty from 'shared/hasOwnProperty'; import isArray from 'shared/isArray'; export type ClientReference = JSResourceReference; +export type ServerReference = T; +export type ServerReferenceMetadata = {}; import type { Destination, BundlerConfig, - ModuleMetaData, + ClientReferenceMetadata, } from 'ReactFlightDOMRelayServerIntegration'; import {resolveModelToJSON} from 'react-server/src/ReactFlightServer'; import { emitRow, - resolveModuleMetaData as resolveModuleMetaDataImpl, + resolveClientReferenceMetadata as resolveClientReferenceMetadataImpl, close, } from 'ReactFlightDOMRelayServerIntegration'; export type { Destination, BundlerConfig, - ModuleMetaData, + ClientReferenceMetadata, } from 'ReactFlightDOMRelayServerIntegration'; export function isClientReference(reference: Object): boolean { return reference instanceof JSResourceReferenceImpl; } +export function isServerReference(reference: Object): boolean { + return false; +} + export type ClientReferenceKey = ClientReference; export function getClientReferenceKey( @@ -53,11 +59,18 @@ export function getClientReferenceKey( return reference; } -export function resolveModuleMetaData( +export function resolveClientReferenceMetadata( config: BundlerConfig, resource: ClientReference, -): ModuleMetaData { - return resolveModuleMetaDataImpl(config, resource); +): ClientReferenceMetadata { + return resolveClientReferenceMetadataImpl(config, resource); +} + +export function resolveServerReferenceMetadata( + config: BundlerConfig, + resource: ServerReference, +): ServerReferenceMetadata { + throw new Error('Not implemented.'); } export type Chunk = RowEncoding; @@ -162,13 +175,13 @@ export function processReferenceChunk( return ['O', id, reference]; } -export function processModuleChunk( +export function processImportChunk( request: Request, id: number, - moduleMetaData: ModuleMetaData, + clientReferenceMetadata: ClientReferenceMetadata, ): Chunk { - // The moduleMetaData is already a JSON serializable value. - return ['I', id, moduleMetaData]; + // The clientReferenceMetadata is already a JSON serializable value. + return ['I', id, clientReferenceMetadata]; } export function scheduleWork(callback: () => void) { diff --git a/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js b/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js index 50cc6f38221b3..1132c75cf7146 100644 --- a/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js +++ b/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayClientIntegration.js @@ -10,12 +10,12 @@ import JSResourceReferenceImpl from 'JSResourceReferenceImpl'; const ReactFlightDOMRelayClientIntegration = { - resolveClientReference(moduleData) { - return new JSResourceReferenceImpl(moduleData); + resolveClientReference(metadata) { + return new JSResourceReferenceImpl(metadata); }, - preloadModule(moduleReference) {}, - requireModule(moduleReference) { - return moduleReference._moduleId; + preloadModule(clientReference) {}, + requireModule(clientReference) { + return clientReference._moduleId; }, }; diff --git a/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js b/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js index 138b5edaf7d98..769e61b1adc25 100644 --- a/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js +++ b/packages/react-server-dom-relay/src/__mocks__/ReactFlightDOMRelayServerIntegration.js @@ -12,7 +12,7 @@ const ReactFlightDOMRelayServerIntegration = { destination.push(json); }, close(destination) {}, - resolveModuleMetaData(config, resource) { + resolveClientReferenceMetadata(config, resource) { return resource._moduleId; }, }; diff --git a/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js b/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js index 12cbc8b54eeb7..739f0160ecb82 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js +++ b/packages/react-server-dom-webpack/src/ReactFlightClientWebpackBundlerConfig.js @@ -15,13 +15,13 @@ import type { export type WebpackSSRMap = { [clientId: string]: { - [clientExportName: string]: ModuleMetaData, + [clientExportName: string]: ClientReferenceMetadata, }, }; export type BundlerConfig = null | WebpackSSRMap; -export opaque type ModuleMetaData = { +export opaque type ClientReferenceMetadata = { id: string, chunks: Array, name: string, @@ -29,15 +29,15 @@ export opaque type ModuleMetaData = { }; // eslint-disable-next-line no-unused-vars -export opaque type ClientReference = ModuleMetaData; +export opaque type ClientReference = ClientReferenceMetadata; export function resolveClientReference( bundlerConfig: BundlerConfig, - moduleData: ModuleMetaData, + metadata: ClientReferenceMetadata, ): ClientReference { if (bundlerConfig) { - const resolvedModuleData = bundlerConfig[moduleData.id][moduleData.name]; - if (moduleData.async) { + const resolvedModuleData = bundlerConfig[metadata.id][metadata.name]; + if (metadata.async) { return { id: resolvedModuleData.id, chunks: resolvedModuleData.chunks, @@ -48,7 +48,7 @@ export function resolveClientReference( return resolvedModuleData; } } - return moduleData; + return metadata; } // The chunk cache contains all the chunks we've preloaded so far. @@ -64,9 +64,9 @@ function ignoreReject() { // Start preloading the modules since we might need them soon. // This function doesn't suspend. export function preloadModule( - moduleData: ClientReference, + metadata: ClientReference, ): null | Thenable { - const chunks = moduleData.chunks; + const chunks = metadata.chunks; const promises = []; for (let i = 0; i < chunks.length; i++) { const chunkId = chunks[i]; @@ -82,8 +82,8 @@ export function preloadModule( promises.push(entry); } } - if (moduleData.async) { - const existingPromise = asyncModuleCache.get(moduleData.id); + if (metadata.async) { + const existingPromise = asyncModuleCache.get(metadata.id); if (existingPromise) { if (existingPromise.status === 'fulfilled') { return null; @@ -91,7 +91,7 @@ export function preloadModule( return existingPromise; } else { const modulePromise: Thenable = Promise.all(promises).then(() => { - return __webpack_require__(moduleData.id); + return __webpack_require__(metadata.id); }); modulePromise.then( value => { @@ -107,7 +107,7 @@ export function preloadModule( rejectedThenable.reason = reason; }, ); - asyncModuleCache.set(moduleData.id, modulePromise); + asyncModuleCache.set(metadata.id, modulePromise); return modulePromise; } } else if (promises.length > 0) { @@ -119,29 +119,29 @@ export function preloadModule( // Actually require the module or suspend if it's not yet ready. // Increase priority if necessary. -export function requireModule(moduleData: ClientReference): T { +export function requireModule(metadata: ClientReference): T { let moduleExports; - if (moduleData.async) { + if (metadata.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); + const promise: any = asyncModuleCache.get(metadata.id); if (promise.status === 'fulfilled') { moduleExports = promise.value; } else { throw promise.reason; } } else { - moduleExports = __webpack_require__(moduleData.id); + moduleExports = __webpack_require__(metadata.id); } - if (moduleData.name === '*') { + if (metadata.name === '*') { // This is a placeholder value that represents that the caller imported this // as a CommonJS module as is. return moduleExports; } - if (moduleData.name === '') { + if (metadata.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]; + return moduleExports[metadata.name]; } diff --git a/packages/react-server-dom-webpack/src/ReactFlightDOMClient.js b/packages/react-server-dom-webpack/src/ReactFlightDOMClient.js index 1c4f53a0aa438..f2b9d0d567e4b 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightDOMClient.js +++ b/packages/react-server-dom-webpack/src/ReactFlightDOMClient.js @@ -22,8 +22,14 @@ import { close, } from 'react-client/src/ReactFlightClientStream'; +type CallServerCallback = ( + {filepath: string, name: string}, + args: A, +) => Promise; + export type Options = { moduleMap?: BundlerConfig, + callServer?: CallServerCallback, }; function startReadingFromStream( @@ -59,6 +65,7 @@ function createFromReadableStream( ): Thenable { const response: FlightResponse = createResponse( options && options.moduleMap ? options.moduleMap : null, + options && options.callServer ? options.callServer : undefined, ); startReadingFromStream(response, stream); return getRoot(response); @@ -70,6 +77,7 @@ function createFromFetch( ): Thenable { const response: FlightResponse = createResponse( options && options.moduleMap ? options.moduleMap : null, + options && options.callServer ? options.callServer : undefined, ); promiseForResponse.then( function (r) { @@ -88,6 +96,7 @@ function createFromXHR( ): Thenable { const response: FlightResponse = createResponse( options && options.moduleMap ? options.moduleMap : null, + options && options.callServer ? options.callServer : undefined, ); let processedLength = 0; function progress(e: ProgressEvent): void { diff --git a/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js b/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js index c662d6d51f243..bfa27b4af8136 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js +++ b/packages/react-server-dom-webpack/src/ReactFlightServerWebpackBundlerConfig.js @@ -7,14 +7,29 @@ * @flow */ +import type {ReactModel} from 'react-server/src/ReactFlightServer'; + type WebpackMap = { [filepath: string]: { - [name: string]: ModuleMetaData, + [name: string]: ClientReferenceMetadata, }, }; export type BundlerConfig = WebpackMap; +export type ServerReference = T & { + $$typeof: symbol, + $$filepath: string, + $$name: string, + $$bound: Array, +}; + +export type ServerReferenceMetadata = { + id: string, + name: string, + bound: Promise>, +}; + // eslint-disable-next-line no-unused-vars export type ClientReference = { $$typeof: symbol, @@ -23,7 +38,7 @@ export type ClientReference = { async: boolean, }; -export type ModuleMetaData = { +export type ClientReferenceMetadata = { id: string, chunks: Array, name: string, @@ -33,6 +48,7 @@ export type ModuleMetaData = { export type ClientReferenceKey = string; const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference'); +const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference'); export function getClientReferenceKey( reference: ClientReference, @@ -49,10 +65,14 @@ export function isClientReference(reference: Object): boolean { return reference.$$typeof === CLIENT_REFERENCE_TAG; } -export function resolveModuleMetaData( +export function isServerReference(reference: Object): boolean { + return reference.$$typeof === SERVER_REFERENCE_TAG; +} + +export function resolveClientReferenceMetadata( config: BundlerConfig, clientReference: ClientReference, -): ModuleMetaData { +): ClientReferenceMetadata { const resolvedModuleData = config[clientReference.filepath][clientReference.name]; if (clientReference.async) { @@ -66,3 +86,14 @@ export function resolveModuleMetaData( return resolvedModuleData; } } + +export function resolveServerReferenceMetadata( + config: BundlerConfig, + serverReference: ServerReference, +): ServerReferenceMetadata { + return { + id: serverReference.$$filepath, + name: serverReference.$$name, + bound: Promise.resolve(serverReference.$$bound), + }; +} diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js index f6bd8a0bc74c7..297cb62c979fb 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js +++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeLoader.js @@ -95,6 +95,106 @@ export async function getSource( return defaultGetSource(url, context, defaultGetSource); } +function addLocalExportedNames(names: Map, node: any) { + switch (node.type) { + case 'Identifier': + names.set(node.name, node.name); + return; + case 'ObjectPattern': + for (let i = 0; i < node.properties.length; i++) + addLocalExportedNames(names, node.properties[i]); + return; + case 'ArrayPattern': + for (let i = 0; i < node.elements.length; i++) { + const element = node.elements[i]; + if (element) addLocalExportedNames(names, element); + } + return; + case 'Property': + addLocalExportedNames(names, node.value); + return; + case 'AssignmentPattern': + addLocalExportedNames(names, node.left); + return; + case 'RestElement': + addLocalExportedNames(names, node.argument); + return; + case 'ParenthesizedExpression': + addLocalExportedNames(names, node.expression); + return; + } +} + +function transformServerModule( + source: string, + body: any, + url: string, + loader: LoadFunction, +): string { + // If the same local name is exported more than once, we only need one of the names. + const localNames: Map = new Map(); + const localTypes: Map = new Map(); + + for (let i = 0; i < body.length; i++) { + const node = body[i]; + switch (node.type) { + case 'ExportAllDeclaration': + // If export * is used, the other file needs to explicitly opt into "use server" too. + break; + case 'ExportDefaultDeclaration': + if (node.declaration.type === 'Identifier') { + localNames.set(node.declaration.name, 'default'); + } else if (node.declaration.type === 'FunctionDeclaration') { + if (node.declaration.id) { + localNames.set(node.declaration.id.name, 'default'); + localTypes.set(node.declaration.id.name, 'function'); + } else { + // TODO: This needs to be rewritten inline because it doesn't have a local name. + } + } + continue; + case 'ExportNamedDeclaration': + if (node.declaration) { + if (node.declaration.type === 'VariableDeclaration') { + const declarations = node.declaration.declarations; + for (let j = 0; j < declarations.length; j++) { + addLocalExportedNames(localNames, declarations[j].id); + } + } else { + const name = node.declaration.id.name; + localNames.set(name, name); + if (node.declaration.type === 'FunctionDeclaration') { + localTypes.set(name, 'function'); + } + } + } + if (node.specifiers) { + const specifiers = node.specifiers; + for (let j = 0; j < specifiers.length; j++) { + const specifier = specifiers[j]; + localNames.set(specifier.local.name, specifier.exported.name); + } + } + continue; + } + } + + let newSrc = source + '\n\n;'; + localNames.forEach(function (exported, local) { + if (localTypes.get(local) !== 'function') { + // We first check if the export is a function and if so annotate it. + newSrc += 'if (typeof ' + local + ' === "function") '; + } + newSrc += 'Object.defineProperties(' + local + ',{'; + newSrc += '$$typeof: {value: Symbol.for("react.server.reference")},'; + newSrc += '$$filepath: {value: ' + JSON.stringify(url) + '},'; + newSrc += '$$name: { value: ' + JSON.stringify(exported) + '},'; + newSrc += '$$bound: { value: [] }'; + newSrc += '});\n'; + }); + return newSrc; +} + function addExportNames(names: Array, node: any) { switch (node.type) { case 'Identifier': @@ -199,39 +299,12 @@ async function parseExportNamesInto( } async function transformClientModule( - source: string, + body: any, url: string, loader: LoadFunction, ): Promise { const names: Array = []; - // Do a quick check for the exact string. If it doesn't exist, don't - // bother parsing. - if (source.indexOf('use client') === -1) { - return source; - } - - const {body} = acorn.parse(source, { - ecmaVersion: '2019', - sourceType: 'module', - }); - - let useClient = false; - for (let i = 0; i < body.length; i++) { - const node = body[i]; - if (node.type !== 'ExpressionStatement' || !node.directive) { - break; - } - if (node.directive === 'use client') { - useClient = true; - break; - } - } - - if (!useClient) { - return source; - } - await parseExportNamesInto(body, names, url, loader); let newSrc = @@ -294,6 +367,57 @@ async function loadClientImport( return {format: 'module', source: result.source}; } +async function transformModuleIfNeeded( + source: string, + url: string, + loader: LoadFunction, +): Promise { + // Do a quick check for the exact string. If it doesn't exist, don't + // bother parsing. + if ( + source.indexOf('use client') === -1 && + source.indexOf('use server') === -1 + ) { + return source; + } + + const {body} = acorn.parse(source, { + ecmaVersion: '2019', + sourceType: 'module', + }); + + let useClient = false; + let useServer = false; + for (let i = 0; i < body.length; i++) { + const node = body[i]; + if (node.type !== 'ExpressionStatement' || !node.directive) { + break; + } + if (node.directive === 'use client') { + useClient = true; + } + if (node.directive === 'use server') { + useServer = true; + } + } + + if (!useClient && !useServer) { + return source; + } + + if (useClient && useServer) { + throw new Error( + 'Cannot have both "use client" and "use server" directives in the same file.', + ); + } + + if (useClient) { + return transformClientModule(body, url, loader); + } + + return transformServerModule(source, body, url, loader); +} + export async function transformSource( source: Source, context: TransformSourceContext, @@ -309,7 +433,7 @@ export async function transformSource( if (typeof transformedSource !== 'string') { throw new Error('Expected source to have been transformed to a string.'); } - const newSrc = await transformClientModule( + const newSrc = await transformModuleIfNeeded( transformedSource, context.url, (url: string, ctx: LoadContext, defaultLoad: LoadFunction) => { @@ -331,7 +455,11 @@ export async function load( if (typeof result.source !== 'string') { throw new Error('Expected source to have been loaded into a string.'); } - const newSrc = await transformClientModule(result.source, url, defaultLoad); + const newSrc = await transformModuleIfNeeded( + result.source, + url, + defaultLoad, + ); return {format: 'module', source: newSrc}; } return defaultLoad(url, context, defaultLoad); diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js index b4bc8663887a8..7baab1c44c6ef 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js +++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackNodeRegister.js @@ -15,8 +15,27 @@ const Module = require('module'); module.exports = function register() { const CLIENT_REFERENCE = Symbol.for('react.client.reference'); + const SERVER_REFERENCE = Symbol.for('react.server.reference'); const PROMISE_PROTOTYPE = Promise.prototype; + // Patch bind on the server to ensure that this creates another + // bound server reference with the additional arguments. + const originalBind = Function.prototype.bind; + /*eslint-disable no-extend-native */ + Function.prototype.bind = (function bind(this: any, self: any) { + // $FlowFixMe[unsupported-syntax] + const newFn = originalBind.apply(this, arguments); + if (this.$$typeof === SERVER_REFERENCE) { + // $FlowFixMe[method-unbinding] + const args = Array.prototype.slice.call(arguments, 1); + newFn.$$typeof = SERVER_REFERENCE; + newFn.$$filepath = this.$$filepath; + newFn.$$name = this.$$name; + newFn.$$bound = this.$$bound.concat(args); + } + return newFn; + }: any); + const deepProxyHandlers = { get: function (target: Function, name: string, receiver: Proxy) { switch (name) { @@ -216,7 +235,10 @@ module.exports = function register() { ): void { // Do a quick check for the exact string. If it doesn't exist, don't // bother parsing. - if (content.indexOf('use client') === -1) { + if ( + content.indexOf('use client') === -1 && + content.indexOf('use server') === -1 + ) { return originalCompile.apply(this, arguments); } @@ -226,6 +248,7 @@ module.exports = function register() { }); let useClient = false; + let useServer = false; for (let i = 0; i < body.length; i++) { const node = body[i]; if (node.type !== 'ExpressionStatement' || !node.directive) { @@ -233,23 +256,68 @@ module.exports = function register() { } if (node.directive === 'use client') { useClient = true; - break; + } + if (node.directive === 'use server') { + useServer = true; } } - if (!useClient) { + if (!useClient && !useServer) { return originalCompile.apply(this, arguments); } - const moduleId: string = (url.pathToFileURL(filename).href: any); - const clientReference = Object.defineProperties(({}: any), { - // Represents the whole Module 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 - this.exports = new Proxy(clientReference, proxyHandlers); + if (useClient && useServer) { + throw new Error( + 'Cannot have both "use client" and "use server" directives in the same file.', + ); + } + + if (useClient) { + const moduleId: string = (url.pathToFileURL(filename).href: any); + const clientReference = Object.defineProperties(({}: any), { + // Represents the whole Module 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 + this.exports = new Proxy(clientReference, proxyHandlers); + } + + if (useServer) { + originalCompile.apply(this, arguments); + + const moduleId: string = (url.pathToFileURL(filename).href: any); + + const exports = this.exports; + + // This module is imported server to server, but opts in to exposing functions by + // reference. If there are any functions in the export. + if (typeof exports === 'function') { + // The module exports a function directly, + Object.defineProperties((exports: any), { + // Represents the whole Module object instead of a particular import. + $$typeof: {value: SERVER_REFERENCE}, + $$filepath: {value: moduleId}, + $$name: {value: '*'}, + $$bound: {value: []}, + }); + } else { + const keys = Object.keys(exports); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const value = exports[keys[i]]; + if (typeof value === 'function') { + Object.defineProperties((value: any), { + $$typeof: {value: SERVER_REFERENCE}, + $$filepath: {value: moduleId}, + $$name: {value: key}, + $$bound: {value: []}, + }); + } + } + } + } }; }; 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 a0056d65bdf51..c9cb1562b4f6e 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js @@ -16,8 +16,10 @@ global.TextEncoder = require('util').TextEncoder; global.TextDecoder = require('util').TextDecoder; let clientExports; +let serverExports; let webpackMap; let webpackModules; +let webpackServerMap; let act; let React; let ReactDOMClient; @@ -33,8 +35,10 @@ describe('ReactFlightDOMBrowser', () => { act = require('jest-react').act; const WebpackMock = require('./utils/WebpackMock'); clientExports = WebpackMock.clientExports; + serverExports = WebpackMock.serverExports; webpackMap = WebpackMock.webpackMap; webpackModules = WebpackMock.webpackModules; + webpackServerMap = WebpackMock.webpackServerMap; React = require('react'); ReactDOMClient = require('react-dom/client'); ReactDOMServer = require('react-dom/server.browser'); @@ -478,10 +482,10 @@ describe('ReactFlightDOMBrowser', () => { // Instead, we have to provide a translation from the client meta data to the SSR // meta data. - const ssrMetaData = webpackMap[ClientComponentOnTheServer.filepath]['*']; + const ssrMetadata = webpackMap[ClientComponentOnTheServer.filepath]['*']; const translationMap = { [clientId]: { - '*': ssrMetaData, + '*': ssrMetadata, }, }; @@ -811,4 +815,101 @@ describe('ReactFlightDOMBrowser', () => { }); expect(container.innerHTML).toBe('Hi'); }); + + function requireServerRef(ref) { + const metaData = webpackServerMap[ref.id][ref.name]; + const mod = __webpack_require__(metaData.id); + if (metaData.name === '*') { + return mod; + } + return mod[metaData.name]; + } + + it('can pass a function by reference from server to client', async () => { + let actionProxy; + + function Client({action}) { + actionProxy = action; + return 'Click Me'; + } + + function send(text) { + return text.toUpperCase(); + } + + const ServerModule = serverExports({ + send, + }); + const ClientRef = clientExports(Client); + + const stream = ReactServerDOMWriter.renderToReadableStream( + , + webpackMap, + ); + + const response = ReactServerDOMReader.createFromReadableStream(stream, { + async callServer(ref, args) { + const fn = requireServerRef(ref); + return fn.apply(null, args); + }, + }); + + function App() { + return use(response); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.innerHTML).toBe('Click Me'); + expect(typeof actionProxy).toBe('function'); + expect(actionProxy).not.toBe(send); + + const result = await actionProxy('hi'); + expect(result).toBe('HI'); + }); + + it('can bind arguments to a server reference', async () => { + let actionProxy; + + function Client({action}) { + actionProxy = action; + return 'Click Me'; + } + + const greet = serverExports(function greet(a, b, c) { + return a + ' ' + b + c; + }); + const ClientRef = clientExports(Client); + + const stream = ReactServerDOMWriter.renderToReadableStream( + , + webpackMap, + ); + + const response = ReactServerDOMReader.createFromReadableStream(stream, { + async callServer(ref, args) { + const fn = requireServerRef(ref); + return fn.apply(null, args); + }, + }); + + function App() { + return use(response); + } + + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + await act(async () => { + root.render(); + }); + expect(container.innerHTML).toBe('Click Me'); + expect(typeof actionProxy).toBe('function'); + expect(actionProxy).not.toBe(greet); + + const result = await actionProxy('!'); + expect(result).toBe('Hello World!'); + }); }); 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 3ddfffc57716a..b208cfd030a90 100644 --- a/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js +++ b/packages/react-server-dom-webpack/src/__tests__/utils/WebpackMock.js @@ -11,14 +11,16 @@ const url = require('url'); const Module = require('module'); let webpackModuleIdx = 0; -const webpackModules = {}; +const webpackServerModules = {}; +const webpackClientModules = {}; const webpackErroredModules = {}; -const webpackMap = {}; +const webpackServerMap = {}; +const webpackClientMap = {}; global.__webpack_require__ = function (id) { if (webpackErroredModules[id]) { throw webpackErroredModules[id]; } - return webpackModules[id]; + return webpackClientModules[id] || webpackServerModules[id]; }; const previousCompile = Module.prototype._compile; @@ -37,14 +39,15 @@ if (previousCompile === nodeCompile) { Module.prototype._compile = previousCompile; -exports.webpackMap = webpackMap; -exports.webpackModules = webpackModules; +exports.webpackMap = webpackClientMap; +exports.webpackModules = webpackClientModules; +exports.webpackServerMap = webpackServerMap; exports.clientModuleError = function clientModuleError(moduleError) { const idx = '' + webpackModuleIdx++; webpackErroredModules[idx] = moduleError; const path = url.pathToFileURL(idx).href; - webpackMap[path] = { + webpackClientMap[path] = { '': { id: idx, chunks: [], @@ -63,9 +66,9 @@ exports.clientModuleError = function clientModuleError(moduleError) { exports.clientExports = function clientExports(moduleExports) { const idx = '' + webpackModuleIdx++; - webpackModules[idx] = moduleExports; + webpackClientModules[idx] = moduleExports; const path = url.pathToFileURL(idx).href; - webpackMap[path] = { + webpackClientMap[path] = { '': { id: idx, chunks: [], @@ -81,7 +84,7 @@ exports.clientExports = function clientExports(moduleExports) { moduleExports.then( asyncModuleExports => { for (const name in asyncModuleExports) { - webpackMap[path][name] = { + webpackClientMap[path][name] = { id: idx, chunks: [], name: name, @@ -92,7 +95,7 @@ exports.clientExports = function clientExports(moduleExports) { ); } for (const name in moduleExports) { - webpackMap[path][name] = { + webpackClientMap[path][name] = { id: idx, chunks: [], name: name, @@ -102,3 +105,32 @@ exports.clientExports = function clientExports(moduleExports) { nodeCompile.call(mod, '"use client"', idx); return mod.exports; }; + +// This tests server to server references. There's another case of client to server references. +exports.serverExports = function serverExports(moduleExports) { + const idx = '' + webpackModuleIdx++; + webpackServerModules[idx] = moduleExports; + const path = url.pathToFileURL(idx).href; + webpackServerMap[path] = { + '': { + id: idx, + chunks: [], + name: '', + }, + '*': { + id: idx, + chunks: [], + name: '*', + }, + }; + for (const name in moduleExports) { + webpackServerMap[path][name] = { + id: idx, + chunks: [], + name: name, + }; + } + const mod = {exports: moduleExports}; + nodeCompile.call(mod, '"use server"', idx); + return mod.exports; +}; diff --git a/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js b/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js index f76b9ba817357..cc85a7976e03f 100644 --- a/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js +++ b/packages/react-server-native-relay/src/ReactFlightNativeRelayClientHostConfig.js @@ -11,7 +11,7 @@ import type {JSONValue, ResponseBase} from 'react-client/src/ReactFlightClient'; import type {JSResourceReference} from 'JSResourceReference'; -import type {ModuleMetaData} from 'ReactFlightNativeRelayClientIntegration'; +import type {ClientReferenceMetadata} from 'ReactFlightNativeRelayClientIntegration'; export type ClientReference = JSResourceReference; @@ -29,7 +29,7 @@ import {resolveClientReference as resolveClientReferenceImpl} from 'ReactFlightN import isArray from 'shared/isArray'; -export type {ModuleMetaData} from 'ReactFlightNativeRelayClientIntegration'; +export type {ClientReferenceMetadata} from 'ReactFlightNativeRelayClientIntegration'; export type BundlerConfig = null; @@ -39,9 +39,9 @@ export type Response = ResponseBase; export function resolveClientReference( bundlerConfig: BundlerConfig, - moduleData: ModuleMetaData, + metadata: ClientReferenceMetadata, ): ClientReference { - return resolveClientReferenceImpl(moduleData); + return resolveClientReferenceImpl(metadata); } function parseModelRecursively( diff --git a/packages/react-server-native-relay/src/ReactFlightNativeRelayProtocol.js b/packages/react-server-native-relay/src/ReactFlightNativeRelayProtocol.js index 788fd14293ca3..a242ba36cc923 100644 --- a/packages/react-server-native-relay/src/ReactFlightNativeRelayProtocol.js +++ b/packages/react-server-native-relay/src/ReactFlightNativeRelayProtocol.js @@ -7,7 +7,7 @@ * @flow */ -import type {ModuleMetaData} from 'ReactFlightNativeRelayServerIntegration'; +import type {ClientReferenceMetadata} from 'ReactFlightNativeRelayServerIntegration'; export type JSONValue = | string @@ -19,7 +19,7 @@ export type JSONValue = export type RowEncoding = | ['O', number, JSONValue] - | ['I', number, ModuleMetaData] + | ['I', number, ClientReferenceMetadata] | ['P', number, string] | ['S', number, string] | [ diff --git a/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js b/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js index a58bf22f0c2a9..a317484597a00 100644 --- a/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js +++ b/packages/react-server-native-relay/src/ReactFlightNativeRelayServerHostConfig.js @@ -15,11 +15,13 @@ import type {JSResourceReference} from 'JSResourceReference'; import JSResourceReferenceImpl from 'JSResourceReferenceImpl'; export type ClientReference = JSResourceReference; +export type ServerReference = T; +export type ServerReferenceMetadata = {}; import type { Destination, BundlerConfig, - ModuleMetaData, + ClientReferenceMetadata, } from 'ReactFlightNativeRelayServerIntegration'; import {resolveModelToJSON} from 'react-server/src/ReactFlightServer'; @@ -27,19 +29,23 @@ import {resolveModelToJSON} from 'react-server/src/ReactFlightServer'; import { emitRow, close, - resolveModuleMetaData as resolveModuleMetaDataImpl, + resolveClientReferenceMetadata as resolveClientReferenceMetadataImpl, } from 'ReactFlightNativeRelayServerIntegration'; export type { Destination, BundlerConfig, - ModuleMetaData, + ClientReferenceMetadata, } from 'ReactFlightNativeRelayServerIntegration'; export function isClientReference(reference: Object): boolean { return reference instanceof JSResourceReferenceImpl; } +export function isServerReference(reference: Object): boolean { + return false; +} + export type ClientReferenceKey = ClientReference; export function getClientReferenceKey( @@ -50,11 +56,18 @@ export function getClientReferenceKey( return reference; } -export function resolveModuleMetaData( +export function resolveClientReferenceMetadata( config: BundlerConfig, resource: ClientReference, -): ModuleMetaData { - return resolveModuleMetaDataImpl(config, resource); +): ClientReferenceMetadata { + return resolveClientReferenceMetadataImpl(config, resource); +} + +export function resolveServerReferenceMetadata( + config: BundlerConfig, + resource: ServerReference, +): ServerReferenceMetadata { + throw new Error('Not implemented.'); } export type Chunk = RowEncoding; @@ -157,13 +170,13 @@ export function processReferenceChunk( return ['O', id, reference]; } -export function processModuleChunk( +export function processImportChunk( request: Request, id: number, - moduleMetaData: ModuleMetaData, + clientReferenceMetadata: ClientReferenceMetadata, ): Chunk { - // The moduleMetaData is already a JSON serializable value. - return ['I', id, moduleMetaData]; + // The clientReferenceMetadata is already a JSON serializable value. + return ['I', id, clientReferenceMetadata]; } export function scheduleWork(callback: () => void) { diff --git a/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayClientIntegration.js b/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayClientIntegration.js index ec0f44c840b36..40befb5c70eaf 100644 --- a/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayClientIntegration.js +++ b/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayClientIntegration.js @@ -10,12 +10,12 @@ import JSResourceReferenceImpl from 'JSResourceReferenceImpl'; const ReactFlightNativeRelayClientIntegration = { - resolveClientReference(moduleData) { - return new JSResourceReferenceImpl(moduleData); + resolveClientReference(metadata) { + return new JSResourceReferenceImpl(metadata); }, - preloadModule(moduleReference) {}, - requireModule(moduleReference) { - return moduleReference._moduleId; + preloadModule(clientReference) {}, + requireModule(clientReference) { + return clientReference._moduleId; }, }; diff --git a/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayServerIntegration.js b/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayServerIntegration.js index 4eab1733c46b5..52371c59eaba0 100644 --- a/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayServerIntegration.js +++ b/packages/react-server-native-relay/src/__mocks__/ReactFlightNativeRelayServerIntegration.js @@ -12,7 +12,7 @@ const ReactFlightNativeRelayServerIntegration = { destination.push(json); }, close(destination) {}, - resolveModuleMetaData(config, resource) { + resolveClientReferenceMetadata(config, resource) { return resource._moduleId; }, }; diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index b6d395fa7e13b..20800530b831c 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -11,9 +11,11 @@ import type { Destination, Chunk, BundlerConfig, - ModuleMetaData, + ClientReferenceMetadata, ClientReference, ClientReferenceKey, + ServerReference, + ServerReferenceMetadata, } from './ReactFlightServerConfig'; import type {ContextSnapshot} from './ReactFlightNewContext'; import type {ThenableState} from './ReactFlightThenable'; @@ -37,13 +39,15 @@ import { close, closeWithError, processModelChunk, - processModuleChunk, + processImportChunk, processErrorChunkProd, processErrorChunkDev, processReferenceChunk, - resolveModuleMetaData, + resolveClientReferenceMetadata, + resolveServerReferenceMetadata, getClientReferenceKey, isClientReference, + isServerReference, supportsRequestStorage, requestStorage, } from './ReactFlightServerConfig'; @@ -101,7 +105,8 @@ export type ReactModel = | symbol | null | Iterable - | ReactModelObject; + | ReactModelObject + | Promise; type ReactModelObject = {+[key: string]: ReactModel}; @@ -129,11 +134,12 @@ export type Request = { pendingChunks: number, abortableTasks: Set, pingedTasks: Array, - completedModuleChunks: Array, + completedImportChunks: Array, completedJSONChunks: Array, completedErrorChunks: Array, writtenSymbols: Map, - writtenModules: Map, + writtenClientReferences: Map, + writtenServerReferences: Map, number>, writtenProviders: Map, identifierPrefix: string, identifierCount: number, @@ -182,11 +188,12 @@ export function createRequest( pendingChunks: 0, abortableTasks: abortSet, pingedTasks: pingedTasks, - completedModuleChunks: [], - completedJSONChunks: [], - completedErrorChunks: [], + completedImportChunks: ([]: Array), + completedJSONChunks: ([]: Array), + completedErrorChunks: ([]: Array), writtenSymbols: new Map(), - writtenModules: new Map(), + writtenClientReferences: new Map(), + writtenServerReferences: new Map(), writtenProviders: new Map(), identifierPrefix: identifierPrefix || '', identifierCount: 1, @@ -509,6 +516,10 @@ function serializePromiseID(id: number): string { return '$@' + id.toString(16); } +function serializeServerReferenceID(id: number): string { + return '$F' + id.toString(16); +} + function serializeSymbolReference(name: string): string { return '$S' + name; } @@ -521,11 +532,12 @@ function serializeClientReference( request: Request, parent: {+[key: string | number]: ReactModel} | $ReadOnlyArray, key: string, - moduleReference: ClientReference, + clientReference: ClientReference, ): string { - const moduleKey: ClientReferenceKey = getClientReferenceKey(moduleReference); - const writtenModules = request.writtenModules; - const existingId = writtenModules.get(moduleKey); + const clientReferenceKey: ClientReferenceKey = + getClientReferenceKey(clientReference); + const writtenClientReferences = request.writtenClientReferences; + const existingId = writtenClientReferences.get(clientReferenceKey); if (existingId !== undefined) { if (parent[0] === REACT_ELEMENT_TYPE && key === '1') { // If we're encoding the "type" of an element, we can refer @@ -538,23 +550,21 @@ function serializeClientReference( return serializeByValueID(existingId); } try { - const moduleMetaData: ModuleMetaData = resolveModuleMetaData( - request.bundlerConfig, - moduleReference, - ); + const clientReferenceMetadata: ClientReferenceMetadata = + resolveClientReferenceMetadata(request.bundlerConfig, clientReference); request.pendingChunks++; - const moduleId = request.nextChunkId++; - emitModuleChunk(request, moduleId, moduleMetaData); - writtenModules.set(moduleKey, moduleId); + const importId = request.nextChunkId++; + emitImportChunk(request, importId, clientReferenceMetadata); + writtenClientReferences.set(clientReferenceKey, importId); if (parent[0] === REACT_ELEMENT_TYPE && key === '1') { // If we're encoding the "type" of an element, we can refer // to that by a lazy reference instead of directly since React // knows how to deal with lazy values. This lets us suspend // on this component rather than its parent until the code has // loaded. - return serializeLazyID(moduleId); + return serializeLazyID(importId); } - return serializeByValueID(moduleId); + return serializeByValueID(importId); } catch (x) { request.pendingChunks++; const errorId = request.nextChunkId++; @@ -569,6 +579,32 @@ function serializeClientReference( } } +function serializeServerReference( + request: Request, + parent: {+[key: string | number]: ReactModel} | $ReadOnlyArray, + key: string, + serverReference: ServerReference, +): string { + const writtenServerReferences = request.writtenServerReferences; + const existingId = writtenServerReferences.get(serverReference); + if (existingId !== undefined) { + return serializeServerReferenceID(existingId); + } + const serverReferenceMetadata: ServerReferenceMetadata = + resolveServerReferenceMetadata(request.bundlerConfig, serverReference); + request.pendingChunks++; + const metadataId = request.nextChunkId++; + // We assume that this object doesn't suspend. + const processedChunk = processModelChunk( + request, + metadataId, + serverReferenceMetadata, + ); + request.completedJSONChunks.push(processedChunk); + writtenServerReferences.set(serverReference, metadataId); + return serializeServerReferenceID(metadataId); +} + function escapeStringValue(value: string): string { if (value[0] === '$') { // We need to escape $ or @ prefixed strings since we use those to encode @@ -991,6 +1027,7 @@ export function resolveModelToJSON( if (typeof value === 'object') { if (isClientReference(value)) { return serializeClientReference(request, parent, key, (value: any)); + // $FlowFixMe[method-unbinding] } else if (typeof value.then === 'function') { // We assume that any object with a .then property is a "Thenable" type, // or a Promise type. Either of which can be represented by a Promise. @@ -1067,6 +1104,9 @@ export function resolveModelToJSON( if (isClientReference(value)) { return serializeClientReference(request, parent, key, (value: any)); } + if (isServerReference(value)) { + return serializeServerReference(request, parent, key, (value: any)); + } if (/^on[A-Z]/.test(key)) { throw new Error( 'Event handlers cannot be passed to Client Component props.' + @@ -1076,7 +1116,7 @@ export function resolveModelToJSON( } else { throw new Error( 'Functions cannot be passed directly to Client Components ' + - "because they're not serializable." + + 'unless you explicitly expose it by marking it with "use server".' + describeObjectForErrorMessage(parent, key), ); } @@ -1203,19 +1243,23 @@ function emitErrorChunkDev( request.completedErrorChunks.push(processedChunk); } -function emitModuleChunk( +function emitImportChunk( request: Request, id: number, - moduleMetaData: ModuleMetaData, + clientReferenceMetadata: ClientReferenceMetadata, ): void { - const processedChunk = processModuleChunk(request, id, moduleMetaData); - request.completedModuleChunks.push(processedChunk); + const processedChunk = processImportChunk( + request, + id, + clientReferenceMetadata, + ); + request.completedImportChunks.push(processedChunk); } function emitSymbolChunk(request: Request, id: number, name: string): void { const symbolReference = serializeSymbolReference(name); const processedChunk = processReferenceChunk(request, id, symbolReference); - request.completedModuleChunks.push(processedChunk); + request.completedImportChunks.push(processedChunk); } function emitProviderChunk( @@ -1367,11 +1411,11 @@ function flushCompletedChunks( try { // We emit module chunks first in the stream so that // they can be preloaded as early as possible. - const moduleChunks = request.completedModuleChunks; + const importsChunks = request.completedImportChunks; let i = 0; - for (; i < moduleChunks.length; i++) { + for (; i < importsChunks.length; i++) { request.pendingChunks--; - const chunk = moduleChunks[i]; + const chunk = importsChunks[i]; const keepWriting: boolean = writeChunkAndReturn(destination, chunk); if (!keepWriting) { request.destination = null; @@ -1379,7 +1423,7 @@ function flushCompletedChunks( break; } } - moduleChunks.splice(0, i); + importsChunks.splice(0, i); // Next comes model data. const jsonChunks = request.completedJSONChunks; i = 0; diff --git a/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js b/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js index 0d2f84dbecc27..b53f0424c866f 100644 --- a/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js +++ b/packages/react-server/src/ReactFlightServerBundlerConfigCustom.js @@ -11,8 +11,14 @@ declare var $$$hostConfig: any; export opaque type BundlerConfig = mixed; export opaque type ClientReference = mixed; // eslint-disable-line no-unused-vars -export opaque type ModuleMetaData: any = mixed; +export opaque type ServerReference = mixed; // eslint-disable-line no-unused-vars +export opaque type ClientReferenceMetadata: any = mixed; +export opaque type ServerReferenceMetadata: any = mixed; export opaque type ClientReferenceKey: any = mixed; export const isClientReference = $$$hostConfig.isClientReference; +export const isServerReference = $$$hostConfig.isServerReference; export const getClientReferenceKey = $$$hostConfig.getClientReferenceKey; -export const resolveModuleMetaData = $$$hostConfig.resolveModuleMetaData; +export const resolveClientReferenceMetadata = + $$$hostConfig.resolveClientReferenceMetadata; +export const resolveServerReferenceMetadata = + $$$hostConfig.resolveServerReferenceMetadata; diff --git a/packages/react-server/src/ReactFlightServerConfigStream.js b/packages/react-server/src/ReactFlightServerConfigStream.js index 7364ab86dbfb7..889a856e49437 100644 --- a/packages/react-server/src/ReactFlightServerConfigStream.js +++ b/packages/react-server/src/ReactFlightServerConfigStream.js @@ -141,12 +141,12 @@ export function processReferenceChunk( return stringToChunk(row); } -export function processModuleChunk( +export function processImportChunk( request: Request, id: number, - moduleMetaData: ReactModel, + clientReferenceMetadata: ReactModel, ): Chunk { - const json: string = stringify(moduleMetaData); + const json: string = stringify(clientReferenceMetadata); const row = serializeRowHeader('I', id) + json + '\n'; return stringToChunk(row); } diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 52fa873e633c2..34db8b08e37ca 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -361,7 +361,7 @@ "372": "Cannot call unstable_createEventHandle with \"%s\", as it is not an event known to React.", "373": "This Hook is not supported in Server Components.", "374": "Event handlers cannot be passed to Client Component props.%s\nIf you need interactivity, consider converting part of this to a Client Component.", - "375": "Functions cannot be passed directly to Client Components because they're not serializable.%s", + "375": "Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with \"use server\".%s", "376": "Only global symbols received from Symbol.for(...) can be passed to Client Components. The symbol Symbol.for(%s) cannot be found among global symbols.%s", "377": "BigInt (%s) is not yet supported in Client Component props.%s", "378": "Type %s is not supported in Client Component props.%s", @@ -450,5 +450,6 @@ "462": "Unexpected SuspendedReason. This is a bug in React.", "463": "ReactDOMServer.renderToNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.", "464": "ReactDOMServer.renderToStaticNodeStream(): The Node Stream API is not available in Bun. Use ReactDOMServer.renderToReadableStream() instead.", - "465": "enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle." -} + "465": "enableFizzExternalRuntime without enableFloat is not supported. This should never appear in production, since it means you are using a misconfigured React bundle.", + "466": "Trying to call a function from \"use server\" but the callServer option was not implemented in your router runtime." +} \ No newline at end of file diff --git a/scripts/flow/react-relay-hooks.js b/scripts/flow/react-relay-hooks.js index 4b52e1d857564..62d2d13909cd6 100644 --- a/scripts/flow/react-relay-hooks.js +++ b/scripts/flow/react-relay-hooks.js @@ -46,19 +46,19 @@ declare module 'ReactFlightDOMRelayServerIntegration' { ): void; declare export function close(destination: Destination): void; - declare export type ModuleMetaData = JSONValue; - declare export function resolveModuleMetaData( + declare export type ClientReferenceMetadata = JSONValue; + declare export function resolveClientReferenceMetadata( config: BundlerConfig, resourceReference: JSResourceReference, - ): ModuleMetaData; + ): ClientReferenceMetadata; } declare module 'ReactFlightDOMRelayClientIntegration' { import type {JSResourceReference} from 'JSResourceReference'; - declare export opaque type ModuleMetaData; + declare export opaque type ClientReferenceMetadata; declare export function resolveClientReference( - moduleData: ModuleMetaData, + moduleData: ClientReferenceMetadata, ): JSResourceReference; declare export function preloadModule( moduleReference: JSResourceReference, @@ -79,19 +79,19 @@ declare module 'ReactFlightNativeRelayServerIntegration' { ): void; declare export function close(destination: Destination): void; - declare export type ModuleMetaData = JSONValue; - declare export function resolveModuleMetaData( + declare export type ClientReferenceMetadata = JSONValue; + declare export function resolveClientReferenceMetadata( config: BundlerConfig, resourceReference: JSResourceReference, - ): ModuleMetaData; + ): ClientReferenceMetadata; } declare module 'ReactFlightNativeRelayClientIntegration' { import type {JSResourceReference} from 'JSResourceReference'; - declare export opaque type ModuleMetaData; + declare export opaque type ClientReferenceMetadata; declare export function resolveClientReference( - moduleData: ModuleMetaData, + moduleData: ClientReferenceMetadata, ): JSResourceReference; declare export function preloadModule( moduleReference: JSResourceReference, diff --git a/scripts/jest/setupHostConfigs.js b/scripts/jest/setupHostConfigs.js index 495b1126fc733..14c62ca4b4d92 100644 --- a/scripts/jest/setupHostConfigs.js +++ b/scripts/jest/setupHostConfigs.js @@ -83,8 +83,9 @@ jest.mock('react-server/flight', () => { jest.mock(shimServerFormatConfigPath, () => config); jest.mock('react-server/src/ReactFlightServerBundlerConfigCustom', () => ({ isClientReference: config.isClientReference, + isServerReference: config.isServerReference, getClientReferenceKey: config.getClientReferenceKey, - resolveModuleMetaData: config.resolveModuleMetaData, + resolveClientReferenceMetadata: config.resolveClientReferenceMetadata, })); jest.mock(shimFlightServerConfigPath, () => jest.requireActual( diff --git a/scripts/rollup/build.js b/scripts/rollup/build.js index 73e2e30531268..f4e4198b06186 100644 --- a/scripts/rollup/build.js +++ b/scripts/rollup/build.js @@ -225,6 +225,7 @@ function isProductionBundleType(bundleType) { switch (bundleType) { case NODE_ES2015: case NODE_ESM: + return true; case UMD_DEV: case NODE_DEV: case BUN_DEV: @@ -377,12 +378,18 @@ function getPlugins( // Please don't enable this for anything else! isUMDBundle && entry === 'react-art' && commonjs(), // Apply dead code elimination and/or minification. + // closure doesn't yet support leaving ESM imports intact isProduction && + bundleType !== NODE_ESM && closure({ compilation_level: 'SIMPLE', - language_in: 'ECMASCRIPT_2015', + language_in: 'ECMASCRIPT_2018', language_out: - bundleType === BROWSER_SCRIPT ? 'ECMASCRIPT5' : 'ECMASCRIPT5_STRICT', + bundleType === NODE_ES2015 + ? 'ECMASCRIPT_2018' + : bundleType === BROWSER_SCRIPT + ? 'ECMASCRIPT5' + : 'ECMASCRIPT5_STRICT', env: 'CUSTOM', warning_level: 'QUIET', apply_input_source_maps: false, diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 3b97f2ef2c6b7..e048f6818349e 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -417,7 +417,7 @@ const bundles = [ bundleTypes: [FB_WWW_DEV, FB_WWW_PROD], moduleType: RENDERER, entry: 'react-server-dom-relay/server', - global: 'ReactFlightDOMRelayServer', // TODO: Rename to Writer + global: 'ReactFlightDOMRelayServer', minifyWithProdErrorCodes: false, wrapWithModuleBoundaries: false, externals: [ @@ -465,7 +465,7 @@ const bundles = [ bundleTypes: [RN_FB_DEV, RN_FB_PROD], moduleType: RENDERER, entry: 'react-server-native-relay', - global: 'ReactFlightNativeRelayClient', // TODO: Rename to Reader + global: 'ReactFlightNativeRelayClient', minifyWithProdErrorCodes: true, wrapWithModuleBoundaries: false, externals: [