diff --git a/packages/next/src/client/app-index.tsx b/packages/next/src/client/app-index.tsx index 41722337e1872..3c059896a6fe5 100644 --- a/packages/next/src/client/app-index.tsx +++ b/packages/next/src/client/app-index.tsx @@ -139,10 +139,10 @@ const readable = new ReadableStream({ }, }) -const initialServerResponse = createFromReadableStream(readable, { - callServer, - findSourceMapURL, -}) +const initialServerResponse = createFromReadableStream( + readable, + { callServer, findSourceMapURL } +) // React overrides `.then` and doesn't return a new promise chain, // so we wrap the action queue in a promise to ensure that its value @@ -151,7 +151,7 @@ const initialServerResponse = createFromReadableStream(readable, { const pendingActionQueue: Promise = new Promise( (resolve, reject) => { initialServerResponse.then( - (initialRSCPayload: InitialRSCPayload) => { + (initialRSCPayload) => { resolve( createMutableActionQueue( createInitialRouterState({ @@ -173,7 +173,7 @@ const pendingActionQueue: Promise = new Promise( ) function ServerRoot(): React.ReactNode { - const initialRSCPayload = use(initialServerResponse) + const initialRSCPayload = use(initialServerResponse) const actionQueue = use(pendingActionQueue) const router = ( diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index db9492df7d42f..4d683a293e7fb 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -16,7 +16,7 @@ import { // import { createFromFetch } from 'react-server-dom-webpack/client' // // eslint-disable-next-line import/no-extraneous-dependencies // import { encodeReply } from 'react-server-dom-webpack/client' -const { createFromFetch, encodeReply } = ( +const { createFromFetch, createTemporaryReferenceSet, encodeReply } = ( !!process.env.NEXT_RUNTIME ? // eslint-disable-next-line import/no-extraneous-dependencies require('react-server-dom-webpack/client.edge') @@ -70,7 +70,8 @@ async function fetchServerAction( nextUrl: ReadonlyReducerState['nextUrl'], { actionId, actionArgs }: ServerActionAction ): Promise { - const body = await encodeReply(actionArgs) + const temporaryReferences = createTemporaryReferenceSet() + const body = await encodeReply(actionArgs, { temporaryReferences }) const res = await fetch('', { method: 'POST', @@ -140,7 +141,7 @@ async function fetchServerAction( if (contentType?.startsWith(RSC_CONTENT_TYPE_HEADER)) { const response: ActionFlightResponse = await createFromFetch( Promise.resolve(res), - { callServer, findSourceMapURL } + { callServer, findSourceMapURL, temporaryReferences } ) if (location) { diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 9417c81dcd6df..1c5e98b66d22b 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -44,6 +44,7 @@ import { selectWorkerForForwarding } from './action-utils' import { isNodeNextRequest, isWebNextRequest } from '../base-http/helpers' import { RedirectStatusCode } from '../../client/components/redirect-status-code' import { synchronizeMutableCookies } from '../async-storage/request-store' +import type { TemporaryReferenceSet } from 'react-server-dom-webpack/server.edge' function formDataFromSearchQueryString(query: string) { const searchParams = new URLSearchParams(query) @@ -394,12 +395,11 @@ function limitUntrustedHeaderValueForLogs(value: string) { type ServerModuleMap = Record< string, - | { - id: string - chunks: string[] - name: string - } - | undefined + { + id: string + chunks: string[] + name: string + } > type ServerActionsConfig = { @@ -460,6 +460,8 @@ export async function handleAction({ ) } + let temporaryReferences: TemporaryReferenceSet | undefined + const finalizeAndGenerateFlight: GenerateFlight = (...args) => { // When we switch to the render phase, cookies() will return // `workUnitStore.cookies` instead of `workUnitStore.userspaceMutableCookies`. @@ -562,6 +564,7 @@ export async function handleAction({ actionResult: promise, // if the page was not revalidated, we can skip the rendering the flight tree skipFlight: !workStore.pathWasRevalidated, + temporaryReferences, }), } } @@ -617,19 +620,31 @@ export async function handleAction({ process.env.NEXT_RUNTIME === 'edge' && isWebNextRequest(req) ) { - // Use react-server-dom-webpack/server.edge - const { decodeReply, decodeAction, decodeFormState } = ComponentMod if (!req.body) { throw new Error('invariant: Missing request body.') } // TODO: add body limit + // Use react-server-dom-webpack/server.edge + const { + createTemporaryReferenceSet, + decodeReply, + decodeAction, + decodeFormState, + } = ComponentMod + + temporaryReferences = createTemporaryReferenceSet() + if (isMultipartAction) { // TODO-APP: Add streaming support const formData = await req.request.formData() if (isFetchAction) { - boundActionArguments = await decodeReply(formData, serverModuleMap) + boundActionArguments = await decodeReply( + formData, + serverModuleMap, + { temporaryReferences } + ) } else { const action = await decodeAction(formData, serverModuleMap) if (typeof action === 'function') { @@ -672,11 +687,16 @@ export async function handleAction({ if (isURLEncodedAction) { const formData = formDataFromSearchQueryString(actionData) - boundActionArguments = await decodeReply(formData, serverModuleMap) + boundActionArguments = await decodeReply( + formData, + serverModuleMap, + { temporaryReferences } + ) } else { boundActionArguments = await decodeReply( actionData, - serverModuleMap + serverModuleMap, + { temporaryReferences } ) } } @@ -688,11 +708,16 @@ export async function handleAction({ ) { // Use react-server-dom-webpack/server.node which supports streaming const { + createTemporaryReferenceSet, decodeReply, decodeReplyFromBusboy, decodeAction, decodeFormState, - } = require(`./react-server.node`) + } = require( + `./react-server.node` + ) as typeof import('./react-server.node') + + temporaryReferences = createTemporaryReferenceSet() const { Transform } = require('node:stream') as typeof import('node:stream') @@ -742,7 +767,8 @@ export async function handleAction({ boundActionArguments = await decodeReplyFromBusboy( busboy, - serverModuleMap + serverModuleMap, + { temporaryReferences } ) } else { // React doesn't yet publish a busboy version of decodeAction @@ -772,7 +798,11 @@ export async function handleAction({ // Only warn if it's a server action, otherwise skip for other post requests warnBadServerActionRequest() const actionReturnedState = await action() - formState = await decodeFormState(actionReturnedState, formData) + formState = await decodeFormState( + actionReturnedState, + formData, + serverModuleMap + ) } // Skip the fetch path @@ -799,11 +829,16 @@ export async function handleAction({ if (isURLEncodedAction) { const formData = formDataFromSearchQueryString(actionData) - boundActionArguments = await decodeReply(formData, serverModuleMap) + boundActionArguments = await decodeReply( + formData, + serverModuleMap, + { temporaryReferences } + ) } else { boundActionArguments = await decodeReply( actionData, - serverModuleMap + serverModuleMap, + { temporaryReferences } ) } } @@ -855,6 +890,7 @@ export async function handleAction({ actionResult: Promise.resolve(returnVal), // if the page was not revalidated, or if the action was forwarded from another worker, we can skip the rendering the flight tree skipFlight: !workStore.pathWasRevalidated || actionWasForwarded, + temporaryReferences, }) } }) @@ -929,6 +965,7 @@ export async function handleAction({ result: await finalizeAndGenerateFlight(req, ctx, { skipFlight: false, actionResult: promise, + temporaryReferences, }), } } @@ -963,6 +1000,7 @@ export async function handleAction({ actionResult: promise, // if the page was not revalidated, or if the action was forwarded from another worker, we can skip the rendering the flight tree skipFlight: !workStore.pathWasRevalidated || actionWasForwarded, + temporaryReferences, }), } } diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index b927be0fb39c8..3d4814c4c4882 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -492,6 +492,7 @@ async function generateDynamicFlightRenderResult( skipFlight: boolean componentTree?: CacheNodeSeedData preloadCallbacks?: PreloadCallbacks + temporaryReferences?: WeakMap } ): Promise { const renderOpts = ctx.renderOpts @@ -517,6 +518,7 @@ async function generateDynamicFlightRenderResult( ctx.clientReferenceManifest.clientModules, { onError, + temporaryReferences: options?.temporaryReferences, } ) diff --git a/packages/next/src/server/app-render/entry-base.ts b/packages/next/src/server/app-render/entry-base.ts index 261cb94813187..9fe0b0f4a05f4 100644 --- a/packages/next/src/server/app-render/entry-base.ts +++ b/packages/next/src/server/app-render/entry-base.ts @@ -1,5 +1,6 @@ // eslint-disable-next-line import/no-extraneous-dependencies export { + createTemporaryReferenceSet, renderToReadableStream, decodeReply, decodeAction, diff --git a/packages/next/src/server/app-render/react-server.node.ts b/packages/next/src/server/app-render/react-server.node.ts index 404aa7a87a882..29e42af3891f3 100644 --- a/packages/next/src/server/app-render/react-server.node.ts +++ b/packages/next/src/server/app-render/react-server.node.ts @@ -2,6 +2,7 @@ // eslint-disable-next-line import/no-extraneous-dependencies export { + createTemporaryReferenceSet, decodeReply, decodeReplyFromBusboy, decodeAction, diff --git a/packages/next/types/$$compiled.internal.d.ts b/packages/next/types/$$compiled.internal.d.ts index e4ff405b5c7a6..7ddaa44108097 100644 --- a/packages/next/types/$$compiled.internal.d.ts +++ b/packages/next/types/$$compiled.internal.d.ts @@ -34,20 +34,93 @@ declare module 'next/dist/compiled/react-dom/server' declare module 'next/dist/compiled/react-dom/server.edge' declare module 'next/dist/compiled/browserslist' -declare module 'react-server-dom-webpack/client' +declare module 'react-server-dom-webpack/client' { + export interface Options { + callServer?: CallServerCallback + temporaryReferences?: TemporaryReferenceSet + findSourceMapURL?: FindSourceMapURLCallback + replayConsoleLogs?: boolean + environmentName?: string + } + + type TemporaryReferenceSet = Map + + export type CallServerCallback = ( + id: string, + args: unknown[] + ) => Promise + + export type EncodeFormActionCallback = ( + id: any, + args: Promise + ) => ReactCustomFormAction + + export type ReactCustomFormAction = { + name?: string + action?: string + encType?: string + method?: string + target?: string + data?: null | FormData + } + + export type FindSourceMapURLCallback = ( + fileName: string, + environmentName: string + ) => null | string + + export function createFromFetch( + promiseForResponse: Promise, + options?: Options + ): Promise + + export function createFromReadableStream( + stream: ReadableStream, + options?: Options + ): Promise + + export function createServerReference( + id: string, + callServer: CallServerCallback, + encodeFormAction?: EncodeFormActionCallback, + findSourceMapURL?: FindSourceMapURLCallback, // DEV-only + functionName?: string + ): (...args: unknown[]) => Promise + + export function createTemporaryReferenceSet( + ...args: unknown[] + ): TemporaryReferenceSet + + export function encodeReply( + value: unknown, + options?: { temporaryReferences?: TemporaryReferenceSet } + ): Promise +} + declare module 'react-server-dom-webpack/server.edge' { + export type ImportManifestEntry = { + id: string | number + // chunks is a double indexed array of chunkId / chunkFilename pairs + chunks: ReadonlyArray + name: string + async?: boolean + } + + export type ClientManifest = { + [id: string]: ImportManifestEntry + } + + export type ServerManifest = { + [id: string]: ImportManifestEntry + } + + export type TemporaryReferenceSet = WeakMap + export function renderToReadableStream( model: any, - webpackMap: { - readonly [id: string]: { - readonly id: string | number - readonly chunks: readonly string[] - readonly name: string - readonly async?: boolean - } - }, + webpackMap: ClientManifest, options?: { - temporaryReferences?: string + temporaryReferences?: TemporaryReferenceSet environmentName?: string | (() => string) filterStackFrame?: (url: string, functionName: string) => boolean onError?: (error: unknown) => void @@ -56,15 +129,15 @@ declare module 'react-server-dom-webpack/server.edge' { } ): ReadableStream - export function createTemporaryReferenceSet(...args: any[]): any - - type ServerManifest = {} + export function createTemporaryReferenceSet( + ...args: unknown[] + ): TemporaryReferenceSet export function decodeReply( body: string | FormData, webpackMap: ServerManifest, options?: { - temporaryReferences?: unknown + temporaryReferences?: TemporaryReferenceSet } ): Promise export function decodeAction( @@ -85,7 +158,57 @@ declare module 'react-server-dom-webpack/server.edge' { export function createClientModuleProxy(moduleId: string): unknown } -declare module 'react-server-dom-webpack/server.node' +declare module 'react-server-dom-webpack/server.node' { + import type { Busboy } from 'busboy' + + export type TemporaryReferenceSet = WeakMap + + export type ImportManifestEntry = { + id: string + // chunks is a double indexed array of chunkId / chunkFilename pairs + chunks: Array + name: string + async?: boolean + } + + export type ServerManifest = { + [id: string]: ImportManifestEntry + } + + export type ReactFormState = [ + unknown /* actual state value */, + string /* key path */, + string /* Server Reference ID */, + number /* number of bound arguments */, + ] + + export function createTemporaryReferenceSet( + ...args: unknown[] + ): TemporaryReferenceSet + + export function decodeReplyFromBusboy( + busboyStream: Busboy, + webpackMap: ServerManifest, + options?: { temporaryReferences?: TemporaryReferenceSet } + ): Promise + + export function decodeReply( + body: string | FormData, + webpackMap: ServerManifest, + options?: { temporaryReferences?: TemporaryReferenceSet } + ): Promise + + export function decodeAction( + body: FormData, + serverManifest: ServerManifest + ): Promise<() => unknown> | null + + export function decodeFormState( + actionResult: unknown, + body: FormData, + serverManifest: ServerManifest + ): Promise +} declare module 'react-server-dom-webpack/static.edge' { export function prerender( children: any, @@ -109,7 +232,91 @@ declare module 'react-server-dom-webpack/static.edge' { prelude: ReadableStream }> } -declare module 'react-server-dom-webpack/client.edge' +declare module 'react-server-dom-webpack/client.edge' { + export interface Options { + ssrManifest: SSRManifest + nonce?: string + encodeFormAction?: EncodeFormActionCallback + temporaryReferences?: TemporaryReferenceSet + findSourceMapURL?: FindSourceMapURLCallback + replayConsoleLogs?: boolean + environmentName?: string + } + + export type EncodeFormActionCallback = ( + id: any, + args: Promise + ) => ReactCustomFormAction + + export type ReactCustomFormAction = { + name?: string + action?: string + encType?: string + method?: string + target?: string + data?: null | FormData + } + + export type ImportManifestEntry = { + id: string | number + // chunks is a double indexed array of chunkId / chunkFilename pairs + chunks: ReadonlyArray + name: string + async?: boolean + } + + export interface SSRManifest { + moduleMap: SSRModuleMap + moduleLoading: ModuleLoading | null + } + + export interface SSRModuleMap { + [clientId: string]: { + [clientExportName: string]: ImportManifestEntry + } + } + + export interface ModuleLoading { + prefix: string + crossOrigin?: 'use-credentials' | '' + } + + type TemporaryReferenceSet = Map + + export type CallServerCallback = ( + id: string, + args: unknown[] + ) => Promise + + export type FindSourceMapURLCallback = ( + fileName: string, + environmentName: string + ) => null | string + + export function createFromFetch( + promiseForResponse: Promise, + options?: Options + ): Promise + + export function createFromReadableStream( + stream: ReadableStream, + options?: Options + ): Promise + + export function createServerReference( + id: string, + callServer: CallServerCallback + ): (...args: unknown[]) => Promise + + export function createTemporaryReferenceSet( + ...args: unknown[] + ): TemporaryReferenceSet + + export function encodeReply( + value: unknown, + options?: { temporaryReferences?: TemporaryReferenceSet } + ): Promise +} declare module 'VAR_MODULE_GLOBAL_ERROR' declare module 'VAR_USERLAND' diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index 36406fcf2c612..abb0939c6896d 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -78,14 +78,21 @@ describe('app-dir action handling', () => { await browser.elementByCss('#submit').click() - const logs = await browser.log() - expect( - logs.some((log) => - log.message.includes( - 'Only plain objects, and a few built-ins, can be passed to Server Actions. Classes or null prototypes are not supported.' - ) + await retry(async () => { + const logs = await browser.log() + + expect(logs).toMatchObject( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + isNextDev + ? 'Cannot access value on the server.' + : GENERIC_RSC_ERROR.replace(/^Error: /, '') + ), + }), + ]) ) - ).toBe(true) + }) }) it('should propagate errors from a `text/plain` response to an error boundary', async () => { diff --git a/test/e2e/app-dir/actions/app/error-handling/actions.js b/test/e2e/app-dir/actions/app/error-handling/actions.js index aad8760f1b7ca..a25ba04ef987e 100644 --- a/test/e2e/app-dir/actions/app/error-handling/actions.js +++ b/test/e2e/app-dir/actions/app/error-handling/actions.js @@ -1,5 +1,7 @@ 'use server' -export async function action() { +export async function action(instance) { + // Not allowed to access, as it's just a temporary reference. + console.log(instance.value) return 'action result' } diff --git a/test/e2e/app-dir/actions/app/error-handling/page.js b/test/e2e/app-dir/actions/app/error-handling/page.js index 2c93a95f0b553..3e136f25fad87 100644 --- a/test/e2e/app-dir/actions/app/error-handling/page.js +++ b/test/e2e/app-dir/actions/app/error-handling/page.js @@ -34,4 +34,6 @@ export default function Page() { ) } -class Foo {} +class Foo { + value = 42 +} diff --git a/test/e2e/app-dir/temporary-references/app/edge/page.tsx b/test/e2e/app-dir/temporary-references/app/edge/page.tsx new file mode 100644 index 0000000000000..1759645f4b2f4 --- /dev/null +++ b/test/e2e/app-dir/temporary-references/app/edge/page.tsx @@ -0,0 +1,3 @@ +export const runtime = 'edge' + +export { default } from '../node/page' diff --git a/test/e2e/app-dir/temporary-references/app/layout.tsx b/test/e2e/app-dir/temporary-references/app/layout.tsx new file mode 100644 index 0000000000000..888614deda3ba --- /dev/null +++ b/test/e2e/app-dir/temporary-references/app/layout.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' +export default function Root({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/test/e2e/app-dir/temporary-references/app/node/form.tsx b/test/e2e/app-dir/temporary-references/app/node/form.tsx new file mode 100644 index 0000000000000..b3a9857899db9 --- /dev/null +++ b/test/e2e/app-dir/temporary-references/app/node/form.tsx @@ -0,0 +1,23 @@ +'use client' + +import { useActionState } from 'react' + +export function Form({ + action, +}: { + action: (obj: object | null) => Promise +}) { + const [result, formAction] = useActionState(async () => { + const objA = {} + const objB = await action(objA) + + return objA === objB ? 'identical' : 'kaputt!' + }, 'initial') + + return ( +
+ +

{result}

+
+ ) +} diff --git a/test/e2e/app-dir/temporary-references/app/node/page.tsx b/test/e2e/app-dir/temporary-references/app/node/page.tsx new file mode 100644 index 0000000000000..4d3a35f6c3ef8 --- /dev/null +++ b/test/e2e/app-dir/temporary-references/app/node/page.tsx @@ -0,0 +1,12 @@ +import { Form } from './form' + +export default function Page() { + return ( +
{ + 'use server' + return obj + }} + /> + ) +} diff --git a/test/e2e/app-dir/temporary-references/next.config.js b/test/e2e/app-dir/temporary-references/next.config.js new file mode 100644 index 0000000000000..807126e4cf0bf --- /dev/null +++ b/test/e2e/app-dir/temporary-references/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/temporary-references/temporary-references.test.ts b/test/e2e/app-dir/temporary-references/temporary-references.test.ts new file mode 100644 index 0000000000000..98d6e0c2b3122 --- /dev/null +++ b/test/e2e/app-dir/temporary-references/temporary-references.test.ts @@ -0,0 +1,22 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('temporary-references', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it.each(['edge', 'node'])( + 'should return the same object that was sent to the action (%s)', + async (runtime) => { + const browser = await next.browser('/' + runtime) + expect(await browser.elementByCss('p').text()).toBe('initial') + + await browser.elementByCss('button').click() + + await retry(async () => { + expect(await browser.elementByCss('p').text()).toBe('identical') + }) + } + ) +})