diff --git a/.changeset/rare-jeans-attend.md b/.changeset/rare-jeans-attend.md new file mode 100644 index 00000000000..6154131a0a2 --- /dev/null +++ b/.changeset/rare-jeans-attend.md @@ -0,0 +1,5 @@ +--- +'@vercel/next': patch +--- + +prevent /index from being incorrectly normalized in rewrites diff --git a/packages/next/src/server-build.ts b/packages/next/src/server-build.ts index 38eed96a0df..d5d9a8153c0 100644 --- a/packages/next/src/server-build.ts +++ b/packages/next/src/server-build.ts @@ -212,7 +212,8 @@ export async function serverBuild({ ); const hasActionOutputSupport = semver.gte(nextVersion, ACTION_OUTPUT_SUPPORT_VERSION) && - Boolean(process.env.NEXT_EXPERIMENTAL_STREAMING_ACTIONS); + Boolean(process.env.NEXT_EXPERIMENTAL_STREAMING_ACTIONS) && + !routesManifest.i18n; const projectDir = requiredServerFilesManifest.relativeAppDir ? path.join(baseDir, requiredServerFilesManifest.relativeAppDir) : requiredServerFilesManifest.appDir || entryPath; @@ -2085,6 +2086,22 @@ export async function serverBuild({ ] : []), + // before processing rewrites, remove any special `/index` routes that were added + // as these won't be properly normalized by `afterFilesRewrites` / `dynamicRoutes` + ...(appPathRoutesManifest + ? [ + { + src: path.posix.join( + '/', + entryDirectory, + '/index(\\.action|\\.rsc)' + ), + dest: path.posix.join('/', entryDirectory), + continue: true, + }, + ] + : []), + // These need to come before handle: miss or else they are grouped // with that routing section ...afterFilesRewrites, diff --git a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/app/actions.js b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/app/actions.js new file mode 100644 index 00000000000..58fcfd2ca13 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/app/actions.js @@ -0,0 +1,6 @@ +'use server'; + +export async function increment(value) { + await new Promise(resolve => setTimeout(resolve, 500)); + return value + 1; +} diff --git a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/app/layout.js b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/app/layout.js new file mode 100644 index 00000000000..2fb0ec4c8dd --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/app/layout.js @@ -0,0 +1,10 @@ +export default function Root({ children }) { + return ( + + + Hello World + + {children} + + ); + } diff --git a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/app/static/page.js b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/app/static/page.js new file mode 100644 index 00000000000..d4dca276762 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/app/static/page.js @@ -0,0 +1,25 @@ +"use client"; + +import { useState } from "react"; +import { increment } from "../actions"; + +export default function Home() { + const [count, setCount] = useState(0); + + return ( +
+ {count} + + Static +
+ ); +} diff --git a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/index.test.js b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/index.test.js new file mode 100644 index 00000000000..bb4ee34827d --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/index.test.js @@ -0,0 +1,55 @@ +/* eslint-env jest */ +const path = require('path'); +const { deployAndTest } = require('../../utils'); +const fetch = require('../../../../../test/lib/deployment/fetch-retry'); + +const ctx = {}; + +function findActionId(page, runtime) { + page = `app${page}/page`; // add /app prefix and /page suffix + + for (const [actionId, details] of Object.entries( + ctx.actionManifest[runtime] + )) { + if (details.workers[page]) { + return actionId; + } + } + + throw new Error("Couldn't find action ID"); +} + +describe(`${__dirname.split(path.sep).pop()}`, () => { + beforeAll(async () => { + const info = await deployAndTest(__dirname); + + const actionManifest = await fetch( + `${info.deploymentUrl}/server-reference-manifest.json` + ).then(res => res.json()); + + ctx.actionManifest = actionManifest; + + Object.assign(ctx, info); + }); + + it('should work when there is a rewrite targeting the root page', async () => { + const actionId = findActionId('/static', 'node'); + + const res = await fetch(ctx.deploymentUrl, { + method: 'POST', + body: JSON.stringify([1337]), + headers: { + 'Content-Type': 'text/plain;charset=UTF-8', + 'Next-Action': actionId, + }, + }); + + expect(res.status).toEqual(200); + expect(res.headers.get('x-matched-path')).toBe('/static/'); + expect(res.headers.get('x-vercel-cache')).toBe('BYPASS'); + + const body = await res.text(); + // The action incremented the provided count by 1 + expect(body).toContain('1338'); + }); +}); diff --git a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/next.config.js b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/next.config.js new file mode 100644 index 00000000000..9c60ee3e856 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/next.config.js @@ -0,0 +1,10 @@ +module.exports = { + rewrites() { + return [ + { + source: '/:path*', + destination: '/static/:path*', + }, + ]; + }, +}; diff --git a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/package.json b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/package.json new file mode 100644 index 00000000000..57a17e90e2e --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/package.json @@ -0,0 +1,9 @@ +{ + "dependencies": { + "next": "canary" + }, + "scripts": { + "build": "next build && cp .next/server/server-reference-manifest.json public/" + }, + "ignoreNextjsUpdates": true +} diff --git a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/public/.keep b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/public/.keep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/vercel.json b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/vercel.json new file mode 100644 index 00000000000..3a65db99c60 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming-index-handling/vercel.json @@ -0,0 +1,14 @@ +{ + "builds": [ + { + "src": "package.json", + "use": "@vercel/next" + } + ], + "build": { + "env": { + "NEXT_EXPERIMENTAL_STREAMING_ACTIONS": "1" + } + }, + "probes": [] +} diff --git a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/index.test.js b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/index.test.js index 7ffe4905c0f..b10bce316f9 100644 --- a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/index.test.js +++ b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/index.test.js @@ -15,7 +15,8 @@ function findActionId(page, runtime) { return actionId; } } - return null; + + throw new Error("Couldn't find action ID"); } function generateFormDataPayload(actionId) { @@ -313,6 +314,21 @@ describe(`${__dirname.split(path.sep).pop()}`, () => { expect(res.headers.get('x-edge-runtime')).toBe('1'); } }); + + it('should work on the index route', async () => { + const canonicalPath = '/'; + const actionId = findActionId('', 'node'); + + const res = await fetch( + `${ctx.deploymentUrl}${canonicalPath}`, + generateFormDataPayload(actionId) + ); + + expect(res.status).toEqual(200); + expect(res.headers.get('x-matched-path')).toBe('/index.action'); + expect(res.headers.get('content-type')).toBe('text/x-component'); + expect(res.headers.get('x-vercel-cache')).toBe('MISS'); + }); }); describe('rewrite to index', () => { @@ -330,6 +346,28 @@ describe(`${__dirname.split(path.sep).pop()}`, () => { expect(res.headers.get('content-type')).toBe('text/x-component'); expect(res.headers.get('x-vercel-cache')).toBe('MISS'); }); + + it('should work when entire path is rewritten', async () => { + const actionId = findActionId('/static', 'node'); + + const res = await fetch(ctx.deploymentUrl, { + method: 'POST', + body: JSON.stringify([1337]), + headers: { + 'Content-Type': 'text/plain;charset=UTF-8', + 'Next-Action': actionId, + 'x-rewrite-me': '1', + }, + }); + + expect(res.status).toEqual(200); + expect(res.headers.get('x-matched-path')).toBe('/index.action'); + expect(res.headers.get('x-vercel-cache')).toBe('MISS'); + + const body = await res.text(); + // The action incremented the provided count by 1 + expect(body).toContain('1338'); + }); }); describe('pages', () => { diff --git a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/next.config.js b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/next.config.js index fd6541b69a1..8e2eea34fbe 100644 --- a/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/next.config.js +++ b/packages/next/test/fixtures/00-app-dir-actions-experimental-streaming/next.config.js @@ -21,6 +21,16 @@ module.exports = { source: '/rewritten-to-index', destination: '/?fromRewrite=1', }, + { + source: '/:path*', + destination: '/static/:path*', + has: [ + { + type: 'header', + key: 'x-rewrite-me', + }, + ], + }, ]; }, }; diff --git a/packages/next/test/fixtures/00-app-dir-catchall-index-handling/app/[[...slug]]/page.js b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/app/[[...slug]]/page.js new file mode 100644 index 00000000000..b83ce6ce5df --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/app/[[...slug]]/page.js @@ -0,0 +1,14 @@ +import Link from 'next/link'; + +const Page = ({ params }) => { + return ( +
+
page-param-{params.slug?.[0] ?? ''}
+ Home + Foo + Bar +
+ ); +}; + +export default Page; diff --git a/packages/next/test/fixtures/00-app-dir-catchall-index-handling/app/layout.js b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/app/layout.js new file mode 100644 index 00000000000..2fb0ec4c8dd --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/app/layout.js @@ -0,0 +1,10 @@ +export default function Root({ children }) { + return ( + + + Hello World + + {children} + + ); + } diff --git a/packages/next/test/fixtures/00-app-dir-catchall-index-handling/index.test.js b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/index.test.js new file mode 100644 index 00000000000..dde33d5540d --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/index.test.js @@ -0,0 +1,12 @@ +/* eslint-env jest */ +const path = require('path'); +const { deployAndTest } = require('../../utils'); + +const ctx = {}; + +describe(`${__dirname.split(path.sep).pop()}`, () => { + it('should deploy and pass probe checks', async () => { + const info = await deployAndTest(__dirname); + Object.assign(ctx, info); + }); +}); diff --git a/packages/next/test/fixtures/00-app-dir-catchall-index-handling/next.config.js b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/next.config.js new file mode 100644 index 00000000000..f053ebf7976 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/next.config.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/packages/next/test/fixtures/00-app-dir-catchall-index-handling/package.json b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/package.json new file mode 100644 index 00000000000..7137e5a7d37 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/package.json @@ -0,0 +1,8 @@ +{ + "dependencies": { + "next": "canary", + "react": "19.0.0-rc-f994737d14-20240522", + "react-dom": "19.0.0-rc-f994737d14-20240522" + }, + "ignoreNextjsUpdates": true +} diff --git a/packages/next/test/fixtures/00-app-dir-catchall-index-handling/vercel.json b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/vercel.json new file mode 100644 index 00000000000..ca94d3ec656 --- /dev/null +++ b/packages/next/test/fixtures/00-app-dir-catchall-index-handling/vercel.json @@ -0,0 +1,27 @@ +{ + "builds": [ + { + "src": "package.json", + "use": "@vercel/next" + } + ], + "probes": [ + { + "path": "/", + "status": 200, + "mustContain": "\"page-param-\",\"\"", + "mustNotContain": "\"page-param-\",\"index\"", + "headers": { + "RSC": "1" + } + }, + { + "path": "/foo", + "status": 200, + "mustContain": "\"page-param-\",\"foo\"", + "headers": { + "RSC": "1" + } + } + ] +}