Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[next] Only rewrite next-action requests to .action handlers #11504

Merged
merged 12 commits into from Apr 30, 2024
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>
);
}