Skip to content

Commit

Permalink
[next] Only rewrite next-action requests to .action handlers (#11504)
Browse files Browse the repository at this point in the history
This only rewrites requests that contain a `next-action` header (explicitly indicating it's a server action). A side effect is that POST requests to a server action on a static route, without a next-action header, won't be marked as streaming (but will still execute normally). This is fine as only the handled action needs to stream. 

This relands .action handling behind a feature flag.
  • Loading branch information
ztanner committed Apr 30, 2024
1 parent 1bf04ba commit b1adaf7
Show file tree
Hide file tree
Showing 29 changed files with 664 additions and 11 deletions.
5 changes: 5 additions & 0 deletions .changeset/big-turkeys-smoke.md
@@ -0,0 +1,5 @@
---
"@vercel/next": patch
---

Only rewrite next-action requests to `.action` handlers
2 changes: 2 additions & 0 deletions packages/next/src/index.ts
Expand Up @@ -1928,6 +1928,7 @@ export const build: BuildV2 = async ({
bypassToken: prerenderManifest.bypassToken || '',
isServerMode,
experimentalPPRRoutes,
hasActionOutputSupport: false,
}).then(arr =>
localizeDynamicRoutes(
arr,
Expand Down Expand Up @@ -1958,6 +1959,7 @@ export const build: BuildV2 = async ({
bypassToken: prerenderManifest.bypassToken || '',
isServerMode,
experimentalPPRRoutes,
hasActionOutputSupport: false,
}).then(arr =>
arr.map(route => {
route.src = route.src.replace('^', `^${dynamicPrefix}`);
Expand Down
68 changes: 67 additions & 1 deletion packages/next/src/server-build.ts
Expand Up @@ -51,6 +51,7 @@ import {
normalizePrefetches,
CreateLambdaFromPseudoLayersOptions,
getPostponeResumePathname,
LambdaGroup,
MAX_UNCOMPRESSED_LAMBDA_SIZE,
} from './utils';
import {
Expand All @@ -68,6 +69,7 @@ const CORRECT_NOT_FOUND_ROUTES_VERSION = 'v12.0.1';
const CORRECT_MIDDLEWARE_ORDER_VERSION = 'v12.1.7-canary.29';
const NEXT_DATA_MIDDLEWARE_RESOLVING_VERSION = 'v12.1.7-canary.33';
const EMPTY_ALLOW_QUERY_FOR_PRERENDERED_VERSION = 'v12.2.0';
const ACTION_OUTPUT_SUPPORT_VERSION = 'v14.2.2';
const CORRECTED_MANIFESTS_VERSION = 'v12.2.0';

// Ideally this should be in a Next.js manifest so we can change it in
Expand Down Expand Up @@ -199,6 +201,9 @@ export async function serverBuild({
nextVersion,
EMPTY_ALLOW_QUERY_FOR_PRERENDERED_VERSION
);
const hasActionOutputSupport =
semver.gte(nextVersion, ACTION_OUTPUT_SUPPORT_VERSION) &&
Boolean(process.env.NEXT_EXPERIMENTAL_STREAMING_ACTIONS);
const projectDir = requiredServerFilesManifest.relativeAppDir
? path.join(baseDir, requiredServerFilesManifest.relativeAppDir)
: requiredServerFilesManifest.appDir || entryPath;
Expand Down Expand Up @@ -926,11 +931,23 @@ export async function serverBuild({
inversedAppPathManifest,
});

const appRouterStreamingActionLambdaGroups: LambdaGroup[] = [];

for (const group of appRouterLambdaGroups) {
if (!group.isPrerenders || group.isExperimentalPPR) {
group.isStreaming = true;
}
group.isAppRouter = true;

// We create a streaming variant of the Prerender lambda group
// to support actions that are part of a Prerender
if (hasActionOutputSupport) {
appRouterStreamingActionLambdaGroups.push({
...group,
isActionLambda: true,
isStreaming: true,
});
}
}

for (const group of appRouteHandlersLambdaGroups) {
Expand Down Expand Up @@ -982,6 +999,13 @@ export async function serverBuild({
pseudoLayerBytes: group.pseudoLayerBytes,
uncompressedLayerBytes: group.pseudoLayerUncompressedBytes,
})),
appRouterStreamingPrerenderLambdaGroups:
appRouterStreamingActionLambdaGroups.map(group => ({
pages: group.pages,
isPrerender: group.isPrerenders,
pseudoLayerBytes: group.pseudoLayerBytes,
uncompressedLayerBytes: group.pseudoLayerUncompressedBytes,
})),
appRouteHandlersLambdaGroups: appRouteHandlersLambdaGroups.map(
group => ({
pages: group.pages,
Expand All @@ -999,6 +1023,7 @@ export async function serverBuild({
const combinedGroups = [
...pageLambdaGroups,
...appRouterLambdaGroups,
...appRouterStreamingActionLambdaGroups,
...apiLambdaGroups,
...appRouteHandlersLambdaGroups,
];
Expand Down Expand Up @@ -1208,6 +1233,11 @@ export async function serverBuild({

let outputName = path.posix.join(entryDirectory, pageName);

if (group.isActionLambda) {
// give the streaming prerenders a .action suffix
outputName = `${outputName}.action`;
}

// If this is a PPR page, then we should prefix the output name.
if (isPPR) {
if (!revalidate) {
Expand Down Expand Up @@ -1378,6 +1408,7 @@ export async function serverBuild({
isServerMode: true,
dynamicMiddlewareRouteMap: middleware.dynamicRouteMap,
experimentalPPRRoutes,
hasActionOutputSupport,
}).then(arr =>
localizeDynamicRoutes(
arr,
Expand Down Expand Up @@ -1905,7 +1936,42 @@ export async function serverBuild({
},
]
: []),

...(hasActionOutputSupport
? [
// Create rewrites for streaming prerenders (.action routes)
// This contains separate rewrites for each possible "has" (action header, or content-type)
// Also includes separate handling for index routes which should match to /index.action.
// This follows the same pattern as the rewrites for .rsc files.
{
src: `^${path.posix.join('/', entryDirectory, '/')}`,
dest: path.posix.join('/', entryDirectory, '/index.action'),
has: [
{
type: 'header',
key: 'next-action',
},
],
continue: true,
override: true,
},
{
src: `^${path.posix.join(
'/',
entryDirectory,
'/((?!.+\\.action).+?)(?:/)?$'
)}`,
dest: path.posix.join('/', entryDirectory, '/$1.action'),
has: [
{
type: 'header',
key: 'next-action',
},
],
continue: true,
override: true,
},
]
: []),
{
src: `^${path.posix.join('/', entryDirectory, '/')}`,
has: [
Expand Down
30 changes: 22 additions & 8 deletions packages/next/src/utils.ts
Expand Up @@ -321,6 +321,7 @@ export async function getDynamicRoutes({
isServerMode,
dynamicMiddlewareRouteMap,
experimentalPPRRoutes,
hasActionOutputSupport,
}: {
entryPath: string;
entryDirectory: string;
Expand All @@ -333,6 +334,7 @@ export async function getDynamicRoutes({
isServerMode?: boolean;
dynamicMiddlewareRouteMap?: ReadonlyMap<string, RouteWithSrc>;
experimentalPPRRoutes: ReadonlySet<string>;
hasActionOutputSupport: boolean;
}): Promise<RouteWithSrc[]> {
if (routesManifest) {
switch (routesManifest.version) {
Expand Down Expand Up @@ -423,14 +425,25 @@ export async function getDynamicRoutes({
});
}

routes.push({
...route,
src: route.src.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
'(?:\\.rsc)(?:/)?$'
),
dest: route.dest?.replace(/($|\?)/, '.rsc$1'),
});
if (hasActionOutputSupport) {
routes.push({
...route,
src: route.src.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
'(?<nxtsuffix>(?:\\.action|\\.rsc))(?:/)?$'
),
dest: route.dest?.replace(/($|\?)/, '$nxtsuffix$1'),
});
} else {
routes.push({
...route,
src: route.src.replace(
new RegExp(escapeStringRegexp('(?:/)?$')),
'(?:\\.rsc)(?:/)?$'
),
dest: route.dest?.replace(/($|\?)/, '.rsc$1'),
});
}

routes.push(route);
}
Expand Down Expand Up @@ -1487,6 +1500,7 @@ export type LambdaGroup = {
isStreaming?: boolean;
isPrerenders?: boolean;
isExperimentalPPR?: boolean;
isActionLambda?: boolean;
isPages?: boolean;
isApiLambda: boolean;
pseudoLayer: PseudoLayer;
Expand Down
@@ -0,0 +1,6 @@
'use server';

export async function increment(value) {
await new Promise(resolve => setTimeout(resolve, 500));
return value + 1;
}
@@ -0,0 +1,19 @@
'use client';

import { useState } from 'react';
import { increment } from '../../../actions';

export default function Page() {
const [count, setCount] = useState(0);
async function updateCount() {
const newCount = await increment(count);
setCount(newCount);
}

return (
<form action={updateCount}>
<div id="count">{count}</div>
<button>Submit</button>
</form>
);
}
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic';

export default function Layout({ children }) {
return children;
}
@@ -0,0 +1,19 @@
'use client';

import { useState } from 'react';
import { increment } from '../../../actions';

export default function Page() {
const [count, setCount] = useState(0);
async function updateCount() {
const newCount = await increment(count);
setCount(newCount);
}

return (
<form action={updateCount}>
<div id="count">{count}</div>
<button>Submit</button>
</form>
);
}
@@ -0,0 +1,5 @@
export const dynamic = 'force-static';

export default function Layout({ children }) {
return children;
}
@@ -0,0 +1,19 @@
'use client';

import { useState } from 'react';
import { increment } from '../../actions';

export default function Page() {
const [count, setCount] = useState(0);
async function updateCount() {
const newCount = await increment(count);
setCount(newCount);
}

return (
<form action={updateCount}>
<div id="count">{count}</div>
<button>Submit</button>
</form>
);
}
@@ -0,0 +1,10 @@
export default function Root({ children }) {
return (
<html>
<head>
<title>Hello World</title>
</head>
<body>{children}</body>
</html>
);
}
@@ -0,0 +1,45 @@
'use client';

// @ts-ignore
import { useCallback, useState } from 'react';

function request(method) {
return fetch('/api/test', {
method,
headers: {
'content-type': 'multipart/form-data;.*',
},
});
}

export default function Home() {
const [result, setResult] = useState('Press submit');
const onClick = useCallback(async method => {
const res = await request(method);
const text = await res.text();

setResult(res.ok ? `${method} ${text}` : 'Error: ' + res.status);
}, []);

return (
<>
<div className="flex flex-col items-center justify-center h-screen">
<div className="flex flex-row space-x-2 items-center justify-center">
<button
className="border border-white rounded-sm p-4 mb-4"
onClick={() => onClick('GET')}
>
Submit GET
</button>
<button
className="border border-white rounded-sm p-4 mb-4"
onClick={() => onClick('POST')}
>
Submit POST
</button>
</div>
<div className="text-white">{result}</div>
</div>
</>
);
}
@@ -0,0 +1,5 @@
export const dynamic = 'force-dynamic';

export default function Layout({ children }) {
return children;
}
@@ -0,0 +1,15 @@
import { revalidatePath } from 'next/cache';

export default async function Page() {
async function serverAction() {
'use server';
await new Promise(resolve => setTimeout(resolve, 1000));
revalidatePath('/dynamic');
}

return (
<form action={serverAction}>
<button>Submit</button>
</form>
);
}
@@ -0,0 +1,15 @@
import { revalidatePath } from 'next/cache';

export default async function Page() {
async function serverAction() {
'use server';
await new Promise(resolve => setTimeout(resolve, 1000));
revalidatePath('/dynamic');
}

return (
<form action={serverAction}>
<button>Submit</button>
</form>
);
}

0 comments on commit b1adaf7

Please sign in to comment.