first commit
Some checks failed
Test examples / Test Examples (20) (push) Has been cancelled
Test examples / Test Examples (22) (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Trigger Release / start (push) Has been cancelled
Stale issue handler / stale (push) Has been cancelled
Update Font Data / create-pull-request (push) Has been cancelled
build-and-deploy / deploy-target (push) Has been cancelled
build-and-deploy / build (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-musl - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-unknown-linux-gnu - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-pc-windows-msvc - node@16 (push) Has been cancelled
build-and-deploy / stable - aarch64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / stable - x86_64-apple-darwin - node@16 (push) Has been cancelled
build-and-deploy / build-wasm (nodejs) (push) Has been cancelled
build-and-deploy / build-wasm (web) (push) Has been cancelled
build-and-deploy / Deploy preview tarball (push) Has been cancelled
build-and-deploy / Potentially publish release (push) Has been cancelled
build-and-deploy / publish-turbopack-npm-packages (push) Has been cancelled
build-and-deploy / Deploy examples (push) Has been cancelled
build-and-deploy / thank you, build (push) Has been cancelled
build-and-deploy / Upload Turbopack Bytesize metrics to Datadog (push) Has been cancelled
Rspack Next.js development integration tests / Rspack integration tests (push) Has been cancelled
Rspack Next.js production integration tests / Rspack integration tests (push) Has been cancelled
Turbopack Next.js development integration tests / Next.js integration tests (push) Has been cancelled
Turbopack Next.js production integration tests / Next.js integration tests (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack development test manifest (push) Has been cancelled
Update Rspack test manifest / Update and upload Rspack production test manifest (push) Has been cancelled
Upload bundler test manifests to areweturboyet.com / Upload test results (push) Has been cancelled
Update React / create-pull-request (push) Has been cancelled
test-e2e-project-reset-cron / reset-test-project (push) Has been cancelled
Notify about the top 15 issues/PRs/feature requests (most reacted) in the last 90 days / run (push) Has been cancelled

This commit is contained in:
Arian Tron
2026-03-10 19:37:31 +03:30
commit 61f56f997c
27684 changed files with 2784175 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
import { nextTestSetup } from 'e2e-utils'
describe('_allow-underscored-root-directory', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should not serve app path with underscore', async () => {
const res = await next.fetch('/_handlers')
expect(res.status).toBe(404)
})
it('should pages path with a underscore at the root', async () => {
const res = await next.fetch('/')
await expect(res.text()).resolves.toBe('Hello, world!')
})
it('should serve app path with %5F', async () => {
const res = await next.fetch('/_routable-folder')
await expect(res.text()).resolves.toBe('Hello, world!')
})
})

View File

@@ -0,0 +1 @@
export { GET } from '../_handlers/route'

View File

@@ -0,0 +1,7 @@
export async function GET() {
return new Response('Hello, world!', {
headers: {
'content-type': 'text/plain',
},
})
}

View File

@@ -0,0 +1,8 @@
export default function RootLayout({ children }) {
return (
<html>
<head></head>
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1 @@
export { GET } from './_handlers/route'

View File

@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}
module.exports = nextConfig

View File

@@ -0,0 +1,47 @@
import { nextTestSetup } from 'e2e-utils'
import { waitForNoRedbox, retry } from 'next-test-utils'
describe('app-dir - action-in-pages-router', () => {
const { next, isNextStart } = nextTestSetup({
files: __dirname,
})
it('should not error on fake server action in pages router', async () => {
const browser = await next.browser('/foo')
const button = await browser.elementByCss('button')
await button.click()
await retry(async () => {
const browserLogText = (await browser.log())
.map((item) => item.message)
.join('')
// This is a fake server action, a simple function so it can still work
expect(browserLogText).toContain('action:foo')
await waitForNoRedbox(browser)
})
})
if (isNextStart) {
// Disabling for turbopack because the chunk path are different
if (!process.env.IS_TURBOPACK_TEST) {
it('should not contain server action in page bundle', async () => {
const pageBundle = await next.readFile('.next/server/pages/foo.js')
// Should not contain the RSC client import source for the server action
expect(pageBundle).not.toContain('react-server-dom-webpack/client')
})
}
it('should not contain server action in manifest', async () => {
if (process.env.IS_TURBOPACK_TEST) {
const manifest = JSON.parse(
await next.readFile('.next/server/server-reference-manifest.json')
)
expect(Object.keys(manifest.node).length).toBe(0)
} else {
expect(
await next.hasFile('.next/server/server-reference-manifest.json')
).toBe(false)
}
})
}
})

View File

@@ -0,0 +1,5 @@
'use server'
export async function actionFoo() {
return 'action:foo'
}

View File

@@ -0,0 +1,18 @@
import { actionFoo } from '../actions'
export default function Page() {
return (
<button
onClick={() => {
actionFoo().then((v) => console.log(v))
}}
>
hello
</button>
)
}
// Keep route as dynamic
export async function getServerSideProps() {
return { props: {} }
}

View File

@@ -0,0 +1,42 @@
import { nextTestSetup } from 'e2e-utils'
import { check } from 'next-test-utils'
import { join } from 'path'
describe('app-dir action allowed origins', () => {
const { next, skipped } = nextTestSetup({
files: join(__dirname, 'safe-origins'),
skipDeployment: true,
dependencies: {
'server-only': 'latest',
},
// An arbitrary & random port.
forcedPort: 'random',
})
if (skipped) {
return
}
it('should pass if localhost is set as a safe origin', async function () {
const browser = await next.browser('/')
await browser.elementByCss('button').click()
await check(async () => {
return await browser.elementByCss('#res').text()
}, 'hi')
})
it('should not crash for requests from privacy sensitive contexts', async function () {
const res = await next.fetch('/', {
method: 'POST',
headers: {
Origin: 'null',
'Content-type': 'application/x-www-form-urlencoded',
'Sec-Fetch-Site': 'same-origin',
},
})
expect({ status: res.status }).toEqual({ status: 200 })
})
})

View File

@@ -0,0 +1,33 @@
import { nextTestSetup } from 'e2e-utils'
import { check } from 'next-test-utils'
import { join } from 'path'
describe('app-dir action disallowed origins', () => {
const { next, skipped } = nextTestSetup({
files: join(__dirname, 'unsafe-origins'),
skipDeployment: true,
dependencies: {
'server-only': 'latest',
},
})
if (skipped) {
return
}
// Origin should be localhost
it('should error if x-forwarded-host does not match the origin', async function () {
const browser = await next.browser('/')
await browser.elementByCss('button').click()
await check(async () => {
const t = await browser.elementByCss('#res').text()
return t.includes('Invalid Server Actions request.') ||
// In prod the message is hidden
t.includes('An error occurred in the Server Components render.')
? 'yes'
: 'no'
}, 'yes')
})
})

View File

@@ -0,0 +1,5 @@
'use server'
export async function log() {
return 'hi'
}

View File

@@ -0,0 +1,8 @@
export default function RootLayout({ children }) {
return (
<html>
<head />
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { useState } from 'react'
import { log } from './action'
if (typeof window !== 'undefined') {
// hijack fetch
const originalFetch = window.fetch
window.fetch = function (url, init) {
if (init?.method === 'POST') {
console.log('fetch', url, init)
// override forwarded host
init.headers = init.headers || {}
init.headers['x-forwarded-host'] = 'my-proxy.com'
}
return originalFetch(url, init)
}
}
export default function Page() {
const [res, setRes] = useState(null)
return (
<div>
<div id="res">{res}</div>
<button
onClick={async () => {
try {
setRes(await log())
} catch (err) {
setRes(err.message)
}
}}
>
fetch
</button>
</div>
)
}

View File

@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
module.exports = {
productionBrowserSourceMaps: true,
logging: {
fetches: {},
},
experimental: {
serverActions: {
allowedOrigins: ['localhost:' + process.env.PORT],
},
},
}

View File

@@ -0,0 +1,5 @@
'use server'
export async function log() {
return 'hi'
}

View File

@@ -0,0 +1,8 @@
export default function RootLayout({ children }) {
return (
<html>
<head />
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,41 @@
'use client'
import { useState } from 'react'
import { log } from './action'
if (typeof window !== 'undefined') {
// hijack fetch
const originalFetch = window.fetch
window.fetch = function (url, init) {
if (init?.method === 'POST') {
console.log('fetch', url, init)
// override forwarded host
init.headers = init.headers || {}
init.headers['x-forwarded-host'] = 'my-proxy.com'
}
return originalFetch(url, init)
}
}
export default function Page() {
const [res, setRes] = useState(null)
return (
<div>
<div id="res">{res}</div>
<button
onClick={async () => {
try {
setRes(await log())
} catch (err) {
setRes(err.message)
}
}}
>
fetch
</button>
</div>
)
}

View File

@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
module.exports = {
productionBrowserSourceMaps: true,
logging: {
fetches: {},
},
}

View File

@@ -0,0 +1,9 @@
'use server'
export async function expensiveCalculation() {
console.log('server action invoked')
// sleep for 1 second
await new Promise((resolve) => setTimeout(resolve, 1000))
return Math.random()
}

View File

@@ -0,0 +1,35 @@
'use client'
import React from 'react'
import { expensiveCalculation } from './actions'
export function Form({ randomNum }) {
const [isPending, setIsPending] = React.useState(false)
const [result, setResult] = React.useState(null)
async function handleSubmit(event) {
event.preventDefault()
setIsPending(true)
const result = await expensiveCalculation()
setIsPending(false)
setResult(result)
}
return (
<form
style={{ display: 'flex', gap: '2rem', flexDirection: 'column' }}
id="form"
onSubmit={handleSubmit}
>
<section>
<button style={{ width: 'max-content' }} type="submit" id="submit">
Submit
</button>
{isPending && 'Loading...'}
</section>
<div>Server side rendered number: {randomNum}</div>
{result && <div id="result">RESULT FROM SERVER ACTION: {result}</div>}
</form>
)
}

View File

@@ -0,0 +1,11 @@
import { Form } from './form'
export default async function Page() {
const randomNum = Math.random()
return (
<div>
<Form randomNum={randomNum} />
</div>
)
}

View File

@@ -0,0 +1,8 @@
export default function RootLayout({ children }) {
return (
<html>
<head />
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <div>Hello World</div>
}

View File

@@ -0,0 +1,5 @@
'use server'
export async function addToCart() {
return 'Added to cart!'
}

View File

@@ -0,0 +1,26 @@
'use client'
import { useFormStatus, useFormState } from 'react-dom'
import { addToCart } from './actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" aria-disabled={pending} id="submit">
Add to cart
</button>
)
}
export default function Page() {
const [state, formAction] = useFormState(addToCart)
return (
<>
<h1>Add to cart</h1>
{state && <div id="result">{state}</div>}
<form action={formAction}>
<SubmitButton />
</form>
</>
)
}

View File

@@ -0,0 +1,5 @@
import Link from 'next/link'
export default async function Page() {
return <Link href="../nested-folder/product-category/machine">Machines</Link>
}

View File

@@ -0,0 +1,7 @@
import Link from 'next/link'
export default function Page() {
return (
<Link href="../action-after-redirect">Go to ../action-after-redirect</Link>
)
}

View File

@@ -0,0 +1,9 @@
import Link from 'next/link'
export default function Page() {
return (
<Link href="/middleware-redirect" id="middleware-redirect">
Go to /middleware-redirect
</Link>
)
}

View File

@@ -0,0 +1,47 @@
import { nextTestSetup } from 'e2e-utils'
import { check, waitFor, retry } from 'next-test-utils'
describe('app-dir action handling', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should handle actions correctly after navigation / redirection events', async () => {
const browser = await next.browser('/')
await browser.elementByCss('#middleware-redirect').click()
expect(await browser.elementByCss('#form').text()).not.toContain(
'Loading...'
)
await browser.elementByCss('#submit').click()
await check(() => {
return browser.elementByCss('#form').text()
}, /Loading.../)
// wait for 2 seconds, since the action takes a second to resolve
await waitFor(2000)
expect(await browser.elementByCss('#form').text()).not.toContain(
'Loading...'
)
expect(await browser.elementByCss('#result').text()).toContain(
'RESULT FROM SERVER ACTION'
)
})
it('should handle actions correctly after following a relative link', async () => {
const browser = await next.browser('/nested-folder/products')
await browser.elementByCss('a').click()
await browser.elementByCss('#submit').click()
await retry(async () => {
expect(await browser.elementById('result').text()).toBe('Added to cart!')
})
})
})

View File

@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server'
export function middleware(request) {
if (request.nextUrl.pathname.startsWith('/middleware-redirect')) {
return NextResponse.redirect(new URL('/action-after-redirect', request.url))
}
}
export const matcher = ['/middleware-redirect']

View File

@@ -0,0 +1,3 @@
module.exports = {
experimental: {},
}

View File

@@ -0,0 +1,32 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('actions-revalidate-remount', () => {
const { next } = nextTestSetup({
files: __dirname,
})
it('should not remount the page + loading component when revalidating', async () => {
const browser = await next.browser('/test')
const initialTime = await browser.elementById('time').text()
expect(initialTime).toMatch(/Time: \d+/)
await browser.elementByCss('button').click()
await retry(async () => {
const time = await browser.elementById('time').text()
expect(time).toMatch(/Time: \d+/)
// The time should be updated
expect(initialTime).not.toBe(time)
const logs = (await browser.log()).filter(
(log) => log.message === 'Loading Mounted'
)
// There should not be any loading logs
expect(logs.length).toBe(0)
})
})
})

View File

@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,11 @@
'use client'
import { useEffect } from 'react'
export default function Loading() {
useEffect(() => {
console.log('Root Loading Mounted')
}, [])
return <p>Root Page Loading</p>
}

View File

@@ -0,0 +1,3 @@
export default function Page() {
return <p>hello world</p>
}

View File

@@ -0,0 +1,11 @@
'use client'
import { useEffect } from 'react'
export default function Loading() {
useEffect(() => {
console.log('Loading Mounted')
}, [])
return <p>Test Page Loading</p>
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
import { revalidatePath } from 'next/cache'
export default async function HomePage() {
await new Promise((resolve) => setTimeout(resolve, 200))
return (
<div>
<p id="time">Time: {Date.now()}</p>
<form
action={async () => {
'use server'
revalidatePath('/test')
}}
>
<button>Submit</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}
module.exports = nextConfig

View File

@@ -0,0 +1,43 @@
import { nextTestSetup } from 'e2e-utils'
import { retry, waitFor } from 'next-test-utils'
describe('actions-streaming', () => {
const { next } = nextTestSetup({
files: __dirname,
})
describe('actions returning a ReadableStream', () => {
it('should properly stream the response without buffering', async () => {
const browser = await next.browser('/readable-stream')
await browser.elementById('stream-button').click()
expect(await browser.elementById('stream-button').text()).toBe(
'Streaming...'
)
// If we're streaming properly, we should see the first chunks arrive
// quickly.
expect(await browser.elementByCss('h3').text()).toMatch(
/Received \d+ chunks/
)
expect(await browser.elementById('chunks').text()).toInclude(
'Lorem ipsum dolor sit'
)
// Finally, wait for the response to finish streaming.
await waitFor(5000)
await retry(
async () => {
expect(await browser.elementByCss('h3').text()).toBe(
'Received 50 chunks'
)
expect(await browser.elementById('stream-button').text()).toBe(
'Start Stream'
)
},
10000,
1000
)
})
})
})

View File

@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,9 @@
import Link from 'next/link'
export default function Page() {
return (
<p>
<Link href="/readable-stream">Readable Stream</Link>
</p>
)
}

View File

@@ -0,0 +1,7 @@
'use server'
export async function streamData(origin: string) {
const response = await fetch(new URL('/readable-stream/api', origin))
return response.body!
}

View File

@@ -0,0 +1,20 @@
import { setTimeout } from 'timers/promises'
const loremIpsum =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt.\n'
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 50; i++) {
await setTimeout(100)
controller.enqueue(encoder.encode(loremIpsum))
}
controller.close()
},
})
return new Response(stream, { headers: { 'Content-Type': 'text/plain' } })
}

View File

@@ -0,0 +1,51 @@
'use client'
import { useState } from 'react'
import { streamData } from './actions'
export default function Page() {
const [chunks, setChunks] = useState<string[] | null>(null)
const [isStreaming, setIsStreaming] = useState(false)
const handleClick = async () => {
setChunks(null)
setIsStreaming(true)
const stream = await streamData(window.location.origin)
const reader = stream.getReader()
const decoder = new TextDecoder()
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
const chunk = decoder.decode(value, { stream: true })
setChunks((prev) => (prev ? [...prev, chunk] : [chunk]))
}
} finally {
reader.releaseLock()
setIsStreaming(false)
}
}
return (
<div>
<button disabled={isStreaming} onClick={handleClick} id="stream-button">
{isStreaming ? 'Streaming...' : 'Start Stream'}
</button>
{chunks && (
<>
<h3>Received {chunks.length} chunks</h3>
<ol id="chunks">
{chunks.map((chunk, i) => (
<li key={i}>{chunk}</li>
))}
</ol>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}
module.exports = nextConfig

View File

@@ -0,0 +1,230 @@
import { nextTestSetup } from 'e2e-utils'
import { createRequestTracker } from 'e2e-utils/request-tracker'
import { retry } from 'next-test-utils'
import { outdent } from 'outdent'
describe('unrecognized server actions', () => {
const { next, isNextDeploy, isNextDev } = nextTestSetup({
files: __dirname,
})
let cliOutputPosition: number = 0
beforeEach(() => {
cliOutputPosition = next.cliOutput.length
})
const getLogs = () => {
return next.cliOutput.slice(cliOutputPosition)
}
// This is disabled when deployed because the 404 page will be served as a static route
// which will not support POST requests, and will return a 405 instead.
if (!isNextDeploy) {
it('should 404 when POSTing a non-server-action request to a nonexistent page', async () => {
const res = await next.fetch('/non-existent-route', {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
body: 'foo=bar',
})
const cliOutput = getLogs()
expect(cliOutput).not.toContain('TypeError')
expect(cliOutput).not.toContain(
'Missing `origin` header from a forwarded Server Actions request'
)
expect(res.status).toBe(404)
})
it.each([
{
// encodeReply encodes simple args as plaintext.
name: 'plaintext',
request: {
contentType: 'text/plain;charset=UTF-8',
body: '{}',
},
},
{
// encodeReply encodes complex args as FormData.
// this body is empty and wouldn't match how react encodes an action, but it should be rejected
// before we even get to parsing the FormData, so it doesn't really matter.
name: 'form-data/multipart',
request: {
body: new FormData(),
},
},
])(
'should 404 when POSTing a server action with an unrecognized id to a nonexistent page: $name',
async ({ request: { contentType, body } }) => {
const res = await next.fetch('/non-existent-route', {
method: 'POST',
headers: {
'next-action': '123',
...(contentType ? { 'content-type': contentType } : undefined),
},
// @ts-expect-error: node-fetch types don't seem to like FormData
body,
})
expect(res.status).toBe(404)
const cliOutput = getLogs()
expect(cliOutput).not.toContain('TypeError')
expect(cliOutput).not.toContain(
'Missing `origin` header from a forwarded Server Actions request'
)
expect(cliOutput).toInclude(outdent`
Failed to find Server Action "123". This request might be from an older or newer deployment.
Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
`)
}
)
}
it('should error when POSTing a urlencoded action to a nonexistent page', async () => {
const res = await next.fetch('/non-existent-route', {
method: 'POST',
headers: {
'next-action': '123',
'content-type': 'application/x-www-form-urlencoded',
},
body: 'foo=bar',
})
// On deploy, this would hit the 404 route which is a static page, and returns a 405 instead.
expect(res.status).toBeOneOf([405, 404])
})
describe.each(['nodejs', 'edge'])(
'should error and log a warning when submitting a server action with an unrecognized ID - %s',
(runtime) => {
const testUnrecognizedActionSubmission = async ({
formId,
disableJavaScript,
}: {
formId: string
disableJavaScript: boolean
}) => {
const browser = await next.browser(`/${runtime}/unrecognized-action`, {
disableJavaScript,
})
const requestTracker = createRequestTracker(browser)
const [_, response] = await requestTracker.captureResponse(
async () =>
await browser
.elementByCss(`form#${formId} button[type="submit"]`)
.click(),
{
request: {
method: 'POST',
pathname: `/${runtime}/unrecognized-action`,
},
}
)
if (!disableJavaScript) {
// A fetch action, sent via the router.
expect(response.status()).toBe(404)
// NOTE: we cannot validate the response text, because playwright hangs on `response.text()` for some reason.
expect(response.headers()['content-type']).toStartWith('text/plain')
// The submission should throw and trigger our error boundary.
expect(await browser.elementByCss(`#error-boundary`).text()).toMatch(
/Error boundary: Server Action ".+?" was not found on the server\./
)
// We responded with a 404, but we shouldn't trigger a not-found (either a custom or a default one)
expect(await browser.elementByCss('body').text()).not.toContain(
'Not found'
)
expect(await browser.elementByCss('body').text()).not.toContain(
'my-not-found'
)
if (!isNextDeploy) {
await retry(async () =>
expect(getLogs()).toInclude(outdent`
Failed to find Server Action "decafc0ffeebad01". This request might be from an older or newer deployment.
Read more: https://nextjs.org/docs/messages/failed-to-find-server-action
`)
)
}
} else {
// An MPA action, sent without JS.
// FIXME: When deployed, the request is logged as a 500, but returns a 405.
// We also don't seem to display the error page correctly
if (!isNextDeploy) {
// FIXME: Currently, an unrecognized id in an MPA action results in a 500.
// This is not ideal, and ignores all nested `error.js` files, only showing the topmost one.
expect(response.status()).toBe(500)
if (isNextDev) {
expect(response.headers()['content-type']).toStartWith(
'text/html'
)
} else {
const responseText = await response.text()
expect(responseText).toBe('Internal Server Error')
expect(response.headers()['content-type']).toStartWith(
'text/plain'
)
}
// In dev, the 500 page doesn't have any SSR'd html, so it won't show anything without JS.
if (!isNextDev) {
expect(await browser.elementByCss('body').text()).toContain(
'Internal Server Error'
)
}
if (!isNextDeploy) {
await retry(async () =>
expect(getLogs()).toInclude(
`Error: Failed to find Server Action. This request might be from an older or newer deployment`
)
)
}
}
}
}
it.each([
{
description: 'js enabled',
disableJavaScript: false,
},
{
description: 'js disabled',
disableJavaScript: true,
},
])(
'server action invoked via form - $description',
async ({ disableJavaScript }) => {
await testUnrecognizedActionSubmission({
formId: 'form-direct',
disableJavaScript,
})
}
)
// these forms rely on client-side JS, so we can't test them with JS disabled
it.each([
{
description: 'with simple argument',
formId: 'form-simple-argument',
},
{
description: 'with complex argument',
formId: 'form-complex-argument',
},
])('server action invoked from JS - $description', async ({ formId }) => {
await testUnrecognizedActionSubmission({
formId,
disableJavaScript: false,
})
})
}
)
})

View File

@@ -0,0 +1,3 @@
export const runtime = 'edge'
export { default } from '../../nodejs/unrecognized-action/page'

View File

@@ -0,0 +1,8 @@
export default function RootLayout({ children }) {
return (
<html>
<head />
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,66 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { unstable_isUnrecognizedActionError as isUnrecognizedActionError } from 'next/navigation'
export function FormWithArg<T>({
action,
argument,
id,
children,
}: {
action: (state: string, argument: T) => Promise<string>
argument: T
id: string
children?: React.ReactNode
}) {
const [state, dispatch] = useActionState(
// don't use `bind()`, we want to explicitly avoid getting a FormData argument
// because that always results in a FormData request
(state) => action(state, argument),
'initial-state'
)
return (
<form id={id} action={dispatch}>
<button type="submit">{children}</button>
<span className="form-state">{`${state}`}</span>
</form>
)
}
export function Form({
action,
}: {
action: (state: string, formData: FormData) => Promise<string>
}) {
const [state, dispatch] = useActionState(action, 'initial-state')
return (
<form action={dispatch} id="form-direct">
<button type="submit">Submit server form</button>
<span className="form-state">{`${state}`}</span>
</form>
)
}
export class UnrecognizedActionBoundary extends React.Component<{
children: React.ReactNode
}> {
state = { error: null }
static getDerivedStateFromError(error) {
if (isUnrecognizedActionError(error)) {
return { error }
} else {
throw error
}
}
render() {
if (this.state.error) {
return (
<div id="error-boundary">
Error boundary: {this.state.error.message}
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,59 @@
import * as React from 'react'
import { FormWithArg, Form, UnrecognizedActionBoundary } from './client'
const action = async (...args: any[]) => {
'use server'
console.log('hello from server', ...args)
return 'state-from-server'
}
// simulate client-side version skew by changing the action ID to something the server won't recognize
setServerActionId(action, 'decafc0ffeebad01')
export default function Page() {
return (
<div>
<div>
<UnrecognizedActionBoundary>
<Form action={action} />
</UnrecognizedActionBoundary>
</div>
<div>
<UnrecognizedActionBoundary>
<FormWithArg
action={action}
id="form-simple-argument"
argument={{ foo: 'bar' }}
>
Submit client form with simple argument
</FormWithArg>
</UnrecognizedActionBoundary>
</div>
<div>
<UnrecognizedActionBoundary>
<FormWithArg
action={action}
id="form-complex-argument"
argument={new Map([['foo', Promise.resolve('bar')]])}
>
Submit client form with complex argument
</FormWithArg>
</UnrecognizedActionBoundary>
</div>
</div>
)
}
function setServerActionId(action: (...args: any[]) => any, id: string) {
// React implementation detail: `registerServerReference(func, id)` sets `func.$$id = id`.
const actionWithMetadata = action as typeof action & { $$id?: string }
if (!actionWithMetadata.$$id) {
throw new Error(
`Expected to find server action metadata properties on ${action}`
)
}
Object.defineProperty(actionWithMetadata, '$$id', {
value: id,
configurable: true,
})
}

View File

@@ -0,0 +1,3 @@
export default function NotFound() {
return <h1>my-not-found</h1>
}

View File

@@ -0,0 +1,10 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
productionBrowserSourceMaps: true,
experimental: {
serverSourceMaps: true,
},
}
export default nextConfig

View File

@@ -0,0 +1,31 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from '../../../lib/next-test-utils'
describe('actions-unused-args', () => {
const { next, skipped } = nextTestSetup({
files: __dirname,
// No access to runtime logs when deployed.
skipDeployment: true,
})
if (skipped) {
return
}
it('should not call server actions with unused arguments', async () => {
const browser = await next.browser('/')
const cliOutputLength = next.cliOutput.length
await browser.elementById('action-button').click()
await retry(async () => {
const actionLog = next.cliOutput
.slice(cliOutputLength)
.split('\n')
.find((line) => line.includes('Action called'))
// We expect only 1 argument because the click event from the onClick
// handler should be omitted as an unused argument.
expect(actionLog).toBe('Action called with value: 42 (total args: 1)')
})
})
})

View File

@@ -0,0 +1,15 @@
'use client'
export function Button({
action,
children,
}: {
action: any
children: React.ReactNode
}) {
return (
<button id="action-button" onClick={action.bind(null, 42)}>
{children}
</button>
)
}

View File

@@ -0,0 +1,8 @@
import { ReactNode } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,18 @@
import { Button } from './button'
export default function Page() {
return (
<Button
action={async (value: number) => {
'use server'
console.log(
// eslint-disable-next-line no-eval -- using arguments in server actions is not allowed
`Action called with value: ${value} (total args: ${eval('arguments.length')})`
)
}}
>
Schaltfläche drücken
</Button>
)
}

View File

@@ -0,0 +1,11 @@
/**
* This function accounts for the overhead of encoding the data to be sent
* over the network via a multipart request.
*
* @param {number} megaBytes
* @returns {number}
*/
export function accountForOverhead(megaBytes) {
// We are sending {megaBytes} - 5% to account for encoding overhead
return Math.floor(1024 * 1024 * megaBytes * 0.95)
}

View File

@@ -0,0 +1,42 @@
import { nextTestSetup } from 'e2e-utils'
describe('app-dir action handling - next export', () => {
const { next, isNextStart, skipped } = nextTestSetup({
files: __dirname,
skipStart: true,
skipDeployment: true,
dependencies: {
nanoid: '4.0.1',
'server-only': 'latest',
},
})
if (skipped) return
if (!isNextStart) {
it('skip test for development mode', () => {})
return
}
beforeAll(async () => {
await next.stop()
await next.patchFile(
'next.config.js',
`
module.exports = {
output: 'export'
}
`
)
// interception routes are also not supported with export
await next.remove('app/interception-routes')
try {
await next.start()
} catch {}
})
it('should error when use export output for server actions', async () => {
expect(next.cliOutput).toContain(
`Server Actions are not supported with static export.`
)
})
})

View File

@@ -0,0 +1,3 @@
process.env.TEST_NODE_MIDDLEWARE = 'true'
require('./app-action-form-state.test')

View File

@@ -0,0 +1,74 @@
import { FileRef, nextTestSetup } from 'e2e-utils'
import { check } from 'next-test-utils'
import { join } from 'path'
describe('app-dir action useActionState', () => {
const { next } = nextTestSetup({
files: __dirname,
overrideFiles: process.env.TEST_NODE_MIDDLEWARE
? {
'middleware.js': new FileRef(join(__dirname, 'middleware-node.js')),
}
: {},
dependencies: {
nanoid: '4.0.1',
},
})
it('should support submitting form state with JS', async () => {
const browser = await next.browser('/client/form-state')
await browser.eval(`document.getElementById('name-input').value = 'test'`)
await browser.elementByCss('#submit-form').click()
await check(() => {
return browser.elementByCss('#form-state').text()
}, 'initial-state:test')
})
it('should support submitting form state without JS', async () => {
const browser = await next.browser('/client/form-state', {
disableJavaScript: true,
})
await browser.eval(`document.getElementById('name-input').value = 'test'`)
await browser.elementByCss('#submit-form').click()
// It should inline the form state into HTML so it can still be hydrated.
await check(() => {
return browser.elementByCss('#form-state').text()
}, 'initial-state:test')
})
it('should support hydrating the app from progressively enhanced form request', async () => {
const browser = await next.browser('/client/form-state')
// Simulate a progressively enhanced form request
await browser.eval(`document.getElementById('name-input').value = 'test'`)
await browser.eval(`document.getElementById('form-state-form').submit()`)
await check(() => {
return browser.elementByCss('#form-state').text()
}, 'initial-state:test')
// Should hydrate successfully
await check(() => {
return browser.elementByCss('#hydrated').text()
}, 'hydrated')
})
it('should send the action to the provided permalink with form state when JS disabled', async () => {
const browser = await next.browser('/client/form-state/page-2', {
disableJavaScript: true,
})
// Simulate a progressively enhanced form request
await browser.eval(
`document.getElementById('name-input').value = 'test-permalink'`
)
await browser.eval(`document.getElementById('form-state-form').submit()`)
await check(() => {
return browser.elementByCss('#form-state').text()
}, 'initial-state:test-permalink')
})
})

View File

@@ -0,0 +1,3 @@
process.env.TEST_NODE_MIDDLEWARE = 'true'
require('./app-action.test')

View File

@@ -0,0 +1,83 @@
import { nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
describe('app-dir action progressive enhancement', () => {
const { next } = nextTestSetup({
files: __dirname,
dependencies: {
nanoid: '4.0.1',
'server-only': 'latest',
},
})
it('should support formData and redirect without JS', async () => {
let responseCode: number | undefined
const browser = await next.browser('/server', {
disableJavaScript: true,
beforePageLoad(page) {
page.on('response', (response) => {
const url = new URL(response.url())
const status = response.status()
if (url.pathname.includes('/server')) {
responseCode = status
}
})
},
})
await browser.elementById('name').type('test')
await browser.elementById('submit').click()
await retry(async () => {
expect(await browser.url()).toBe(
`${next.url}/header?name=test&hidden-info=hi`
)
})
expect(responseCode).toBe(303)
})
it('should support actions from client without JS', async () => {
const browser = await next.browser('/server', {
disableJavaScript: true,
})
await browser.elementById('client-name').type('test')
await browser.elementById('there').click()
await retry(async () => {
expect(await browser.url()).toBe(
`${next.url}/header?name=test&hidden-info=hi`
)
})
})
it.each(['edge', 'node'])(
'should support headers and cookies without JS (runtime: %s)',
async (runtime) => {
const browser = await next.browser(`/header/${runtime}/form`, {
disableJavaScript: true,
})
await browser.elementById('get-referer').click()
await retry(async () => {
expect(await browser.elementById('referer').text()).toBe(
`${next.url}/header/${runtime}/form`
)
})
await browser.elementById('set-cookie').click()
await retry(async () => {
expect(await browser.elementById('referer').text()).toBe('<null>')
})
await browser.elementById('get-cookie').click()
await retry(async () => {
expect(await browser.elementById('cookie').text()).toBe('42')
})
}
)
})

View File

@@ -0,0 +1,3 @@
process.env.TEST_NODE_MIDDLEWARE = 'true'
require('./app-action-size-limit-invalid.test')

View File

@@ -0,0 +1,273 @@
import { FileRef, NextInstance, nextTestSetup } from 'e2e-utils'
import { retry } from 'next-test-utils'
import { createRequestTracker } from 'e2e-utils/request-tracker'
import stripAnsi from 'strip-ansi'
import { accountForOverhead } from './account-for-overhead'
import { join } from 'path'
const CONFIG_ERROR =
'Server Actions Size Limit must be a valid number or filesize format larger than 1MB'
describe('app-dir action size limit invalid config', () => {
const { next, isNextStart, isNextDeploy, skipped } = nextTestSetup({
files: __dirname,
overrideFiles: process.env.TEST_NODE_MIDDLEWARE
? {
'middleware.js': new FileRef(join(__dirname, 'middleware-node.js')),
}
: {},
skipStart: true,
dependencies: {
nanoid: '4.0.1',
'server-only': 'latest',
},
})
if (skipped) return
const logs: string[] = []
beforeAll(() => {
const onLog = (log: string) => {
logs.push(stripAnsi(log.trim()))
}
next.on('stdout', onLog)
next.on('stderr', onLog)
})
afterEach(async () => {
logs.length = 0
await next.stop()
})
if (isNextStart) {
it('should error if serverActions.bodySizeLimit config is a negative number', async function () {
await using _ = await patchFileWithCleanup(
next,
'next.config.js',
`
module.exports = {
experimental: {
serverActions: { bodySizeLimit: -3000 },
},
}
`
)
try {
await next.start()
} catch {}
expect(next.cliOutput).toContain(CONFIG_ERROR)
})
it('should error if serverActions.bodySizeLimit config is invalid', async function () {
await using _ = await patchFileWithCleanup(
next,
'next.config.js',
`
module.exports = {
experimental: {
serverActions: { bodySizeLimit: 'testmb' },
},
}
`
)
try {
await next.start()
} catch {}
expect(next.cliOutput).toContain(CONFIG_ERROR)
})
it('should error if serverActions.bodySizeLimit config is a negative size', async function () {
await using _ = await patchFileWithCleanup(
next,
'next.config.js',
`
module.exports = {
experimental: {
serverActions: { bodySizeLimit: '-3000mb' },
},
}
`
)
try {
await next.start()
} catch {}
expect(next.cliOutput).toContain(CONFIG_ERROR)
})
}
describe('should respect the size set in serverActions.bodySizeLimit for plaintext fetch actions', () => {
beforeEach(async () => {
await next.start()
})
it('should not error for requests that stay below the size limit', async () => {
const browser = await next.browser('/file')
const requestTracker = createRequestTracker(browser)
// below the limit: ok
const [, actionResponse] = await requestTracker.captureResponse(
() => browser.elementByCss('#size-1mb').click(),
{ request: { method: 'POST', pathname: '/file' } }
)
expect(actionResponse.status()).toBe(200)
expect(
await actionResponse.request().headerValue('content-type')
).toStartWith('text/plain')
if (!isNextDeploy) {
await retry(() =>
expect(logs).toContainEqual(
expect.stringContaining(`size = ${accountForOverhead(1)}`)
)
)
expect(logs).not.toContainEqual(
expect.stringContaining('Error: Body exceeded 2mb limit')
)
}
})
it('should error for requests that exceed the size limit', async () => {
const browser = await next.browser('/file')
const requestTracker = createRequestTracker(browser)
const [, actionResponse] = await requestTracker.captureResponse(
() => browser.elementByCss('#size-3mb').click(),
{ request: { method: 'POST', pathname: '/file' } }
)
expect(actionResponse.status()).toBe(500) // TODO: 413?
expect(
await actionResponse.request().headerValue('content-type')
).toStartWith('text/plain')
// The error should have been returned to the client and thrown, triggering the nearest error boundary.
expect(await browser.elementByCss('#error').text()).toBe(
'Something went wrong!'
)
if (!isNextDeploy) {
await retry(() => {
expect(logs).toContainEqual(
expect.stringContaining('Error: Body exceeded 2mb limit')
)
expect(logs).toContainEqual(
expect.stringContaining(
'To configure the body size limit for Server Actions, see'
)
)
})
expect(logs).not.toContainEqual(expect.stringMatching(/^size = /))
}
})
})
describe('should respect the size set in serverActions.bodySizeLimit for multipart fetch actions', () => {
beforeEach(async () => {
await next.start()
})
it('should not error for requests that stay below the size limit', async () => {
const browser = await next.browser('/form')
const requestTracker = createRequestTracker(browser)
const [, actionResponse] = await requestTracker.captureResponse(
() => browser.elementByCss('#size-1mb').click(),
{ request: { method: 'POST', pathname: '/form' } }
)
expect(actionResponse.status()).toBe(200)
expect(
await actionResponse.request().headerValue('content-type')
).toStartWith('multipart/form-data')
if (!isNextDeploy) {
await retry(() =>
expect(logs).toContainEqual(
expect.stringContaining(`size = ${accountForOverhead(1)}`)
)
)
expect(logs).not.toContainEqual(
expect.stringContaining('Error: Body exceeded 2mb limit')
)
}
})
it('should not error for requests that are at the size limit', async () => {
const browser = await next.browser('/form')
const requestTracker = createRequestTracker(browser)
const [, actionResponse] = await requestTracker.captureResponse(
() => browser.elementByCss('#size-2mb').click(),
{ request: { method: 'POST', pathname: '/form' } }
)
expect(actionResponse.status()).toBe(200)
expect(
await actionResponse.request().headerValue('content-type')
).toStartWith('multipart/form-data')
if (!isNextDeploy) {
await retry(() =>
expect(logs).toContainEqual(
expect.stringContaining(`size = ${accountForOverhead(2)}`)
)
)
expect(logs).not.toContainEqual(
expect.stringContaining('Error: Body exceeded 2mb limit')
)
}
})
it('should error for requests that exceed the size limit', async () => {
const browser = await next.browser('/form')
const requestTracker = createRequestTracker(browser)
const [, actionResponse] = await requestTracker.captureResponse(
() => browser.elementByCss('#size-3mb').click(),
{ request: { method: 'POST', pathname: '/form' } }
)
expect(actionResponse.status()).toBe(500) // TODO: 413?
expect(
await actionResponse.request().headerValue('content-type')
).toStartWith('multipart/form-data')
// The error should have been returned to the client and thrown, triggering the nearest error boundary.
expect(await browser.elementByCss('#error').text()).toBe(
'Something went wrong!'
)
if (!isNextDeploy) {
await retry(() => {
expect(logs).toContainEqual(
expect.stringContaining('Error: Body exceeded 2mb limit')
)
expect(logs).toContainEqual(
expect.stringContaining(
'To configure the body size limit for Server Actions, see'
)
)
})
expect(logs).not.toContainEqual(expect.stringMatching(/^size = /))
}
})
})
})
async function patchFileWithCleanup(
next: NextInstance,
filename: Parameters<NextInstance['patchFile']>[0],
contents: Parameters<NextInstance['patchFile']>[1]
): Promise<AsyncDisposable> {
const originalFile = (await next.hasFile(filename))
? await next.readFile(filename)
: null
await next.patchFile(filename, contents)
return {
async [Symbol.asyncDispose]() {
if (originalFile === null) {
await next.deleteFile(filename)
} else {
await next.patchFile(filename, originalFile)
}
},
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
'use server'
import { updateTag } from 'next/cache'
export async function slowAction() {
await new Promise((resolve) => setTimeout(resolve, 2000))
return 'slow action completed'
}
export async function slowActionWithRevalidation() {
await new Promise((resolve) => setTimeout(resolve, 2000))
updateTag('cached-random')
return 'slow action with revalidation completed'
}

View File

@@ -0,0 +1,8 @@
export default function DestinationPage() {
return (
<div>
<h1 id="destination-page">Destination Page</h1>
<p>You have navigated to the destination.</p>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import Link from 'next/link'
import { connection } from 'next/server'
export default async function Layout({ children }) {
await connection()
const cachedRandom = await fetch(
'https://next-data-api-endpoint.vercel.app/api/random?key=cached-random',
{ next: { tags: ['cached-random'] } }
).then((res) => res.text())
return (
<div>
<div>
Cached Random: <span id="cached-random">{cachedRandom}</span>
</div>
<div>
<Link id="navigate-destination" href="/action-discarding/destination">
Navigate to Destination
</Link>
</div>
{children}
</div>
)
}

View File

@@ -0,0 +1,27 @@
'use client'
import { slowAction, slowActionWithRevalidation } from './actions'
export default function Page() {
return (
<div>
<h1>Action Discarding Test</h1>
<button
id="slow-action"
onClick={async () => {
await slowAction()
}}
>
Slow Action (No Revalidation)
</button>
<button
id="slow-action-revalidate"
onClick={async () => {
await slowActionWithRevalidation()
}}
>
Slow Action (With Revalidation)
</button>
</div>
)
}

View File

@@ -0,0 +1,13 @@
'use server'
/**
* @param {number[]} largeJson
*/
export async function submitLargePayload(largeJson) {
return {
success: true,
count: largeJson.length,
firstId: largeJson[0],
lastId: largeJson[largeJson.length - 1],
}
}

View File

@@ -0,0 +1,27 @@
'use client'
import { useMemo, useState } from 'react'
import { submitLargePayload } from './actions'
export default function Page() {
const [result, setResult] = useState(null)
const largePayload = useMemo(
() => new Array(10 * 1024).fill(null).map((_, idx) => idx),
[]
)
const handleSubmit = async () => {
const res = await submitLargePayload(largePayload)
setResult(res)
}
return (
<div>
<button onClick={handleSubmit} id="submit-large">
Submit Large Payload
</button>
{result && <div id="result">{JSON.stringify(result)}</div>}
</div>
)
}

View File

@@ -0,0 +1,9 @@
'use server'
export async function getServerData() {
return Math.random()
}
export async function badAction() {
throw new Error('oops')
}

View File

@@ -0,0 +1,48 @@
'use client'
import { useTransition, useState } from 'react'
import { badAction, getServerData } from './actions'
export default function Component() {
const [isPending, startTransition] = useTransition()
const [wasSubmitted, setWasSubmitted] = useState(false)
return (
<>
{wasSubmitted && <div id="submitted-msg">Submitted!</div>}
<button
id="good-action"
disabled={isPending}
onClick={() => {
startTransition(() => {
getServerData()
.catch(() => {
console.log('error caught in user code')
})
.finally(() => {
setWasSubmitted(true)
})
})
}}
>
Submit Action
</button>
<button
id="bad-action"
disabled={isPending}
onClick={() => {
startTransition(() => {
badAction()
.catch(() => {
console.log('error caught in user code')
})
.finally(() => {
setWasSubmitted(true)
})
})
}}
>
Submit Action (Throws)
</button>
</>
)
}

View File

@@ -0,0 +1,16 @@
import { Counter } from '../../../components/Counter'
import { incrementCounter } from '../actions'
export default function Page() {
return (
<div>
<Counter onClick={incrementCounter} />
</div>
)
}
export const revalidate = 60
export async function generateStaticParams() {
return [{ path: ['asdf'] }]
}

View File

@@ -0,0 +1,10 @@
'use server'
let counter = 0
export async function incrementCounter() {
console.log('Button clicked!')
counter++
return counter
}

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }) {
return children
}

View File

@@ -0,0 +1,8 @@
'use server'
import { Hello } from './client-component'
export async function getComponent() {
return {
component: <Hello />,
}
}

View File

@@ -0,0 +1,5 @@
'use client'
export function Hello() {
return <p id="client-component">Hello World</p>
}

View File

@@ -0,0 +1,16 @@
'use client'
import { useActionState } from 'react'
export function Form({ action }) {
const [state, formAction] = useActionState(action, null)
return (
<>
<form action={formAction}>
<button id="trigger-component-load" type="submit">
Trigger Component Load
</button>
</form>
{state?.component}
</>
)
}

View File

@@ -0,0 +1,12 @@
'use client'
import { getComponent } from './actions'
import { Form } from './form'
export default function Page() {
return (
<>
<h1>Server Component loading client component through action</h1>
<Form action={getComponent} />
</>
)
}

View File

@@ -0,0 +1,9 @@
'use server'
// Any arbitrary library just to ensure it's bundled.
// https://github.com/vercel/next.js/pull/51367
import { nanoid } from 'nanoid'
export async function test() {
console.log(nanoid)
}

View File

@@ -0,0 +1,38 @@
'use server'
import 'server-only'
import { redirect } from 'next/navigation'
import { headers, cookies } from 'next/headers'
export async function getHeaders() {
console.log('accept header:', (await headers()).get('accept'))
;(await cookies()).set('test-cookie', Date.now())
}
export async function inc(value) {
return value + 1
}
export async function slowInc(value) {
await new Promise((resolve) => setTimeout(resolve, 2000))
return value + 1
}
export async function dec(value) {
return value - 1
}
export default async function (value) {
console.log('this_is_sensitive_info')
return value * 2
}
export async function redirectAction(path) {
redirect(path)
}
const original = async () => {
console.log('action')
}
export { original as renamed }

View File

@@ -0,0 +1,78 @@
'use client'
import { useState } from 'react'
import double, { inc, dec, redirectAction, getHeaders } from '../actions'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<h1>{count}</h1>
<button
id="inc"
onClick={async () => {
const newCount = await inc(count)
setCount(newCount)
}}
>
+1
</button>
<button
id="dec"
onClick={async () => {
const newCount = await dec(count)
setCount(newCount)
}}
>
-1
</button>
<button
id="double"
onClick={async () => {
const newCount = await double(count)
setCount(newCount)
}}
>
*2
</button>
<form>
<button
id="redirect-relative"
formAction={() => redirectAction('/redirect-target')}
>
redirect to a relative URL
</button>
</form>
<form>
<button
id="redirect-absolute"
formAction={() =>
redirectAction(`${location.origin}/redirect-target`)
}
>
redirect to a absolute URL
</button>
</form>
<form>
<button
id="redirect-external"
formAction={() =>
redirectAction(
'https://next-data-api-endpoint.vercel.app/api/random?page'
)
}
>
redirect external
</button>
</form>
<form>
<button id="get-headers" formAction={() => getHeaders()}>
get headers
</button>
</form>
</div>
)
}
export const runtime = 'edge'

View File

@@ -0,0 +1,5 @@
'use server'
export async function appendName(state, formData) {
return state + ':' + formData.get('name')
}

View File

@@ -0,0 +1,31 @@
'use client'
import { useActionState } from 'react'
import { appendName } from '../actions'
import { useEffect, useState } from 'react'
export default function Page() {
const [state, appendNameFormAction] = useActionState(
appendName,
'initial-state',
'/client/form-state'
)
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(true)
}, [])
return (
<>
<form id="form-state-form" action={appendNameFormAction}>
<p id="form-state">{state}</p>
<input id="name-input" name="name" />
<button id="submit-form" type="submit">
log
</button>
</form>
{hydrated ? <p id="hydrated">hydrated</p> : null}
</>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
import { useActionState } from 'react'
import { appendName } from './actions'
import { useEffect, useState } from 'react'
export default function Page() {
const [state, appendNameFormAction] = useActionState(
appendName,
'initial-state',
'/client/form-state'
)
const [hydrated, setHydrated] = useState(false)
useEffect(() => {
setHydrated(true)
}, [])
return (
<>
<form id="form-state-form" action={appendNameFormAction}>
<p id="form-state">{state}</p>
<input id="name-input" name="name" />
<button id="submit-form" type="submit">
log
</button>
</form>
{hydrated ? <p id="hydrated">hydrated</p> : null}
</>
)
}

View File

@@ -0,0 +1,9 @@
async function noopAction() {
'use server'
}
console.log(!!noopAction())
export default function Layout({ children }) {
return children
}

View File

@@ -0,0 +1,77 @@
'use client'
import { useState } from 'react'
import double, {
inc,
dec,
redirectAction,
getHeaders,
renamed,
slowInc,
} from './actions'
import { test } from './actions-lib'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<h1 id="count">{count}</h1>
<button
id="inc"
onClick={async () => {
const newCount = await inc(count)
setCount(newCount)
// test renamed action
renamed()
}}
>
+1
</button>
<button
id="slow-inc"
onClick={async () => {
const newCount = await slowInc(count)
setCount(newCount)
}}
>
+1 (Slow)
</button>
<button
id="dec"
onClick={async () => {
const newCount = await dec(count)
setCount(newCount)
}}
>
-1
</button>
<button
id="double"
onClick={async () => {
const newCount = await double(count)
setCount(newCount)
}}
>
*2
</button>
<form>
<button
id="redirect-pages"
formAction={() => redirectAction('/pages-dir')}
>
redirect to a pages route
</button>
</form>
<form action={getHeaders}>
<button type="submit" id="get-header">
submit
</button>
</form>
<form action={test}>
<button>test</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,40 @@
'use client'
import { redirectAction } from '../actions'
export default function Page() {
return (
<div>
<form>
<button
id="redirect-relative"
formAction={() => redirectAction('/redirect-target')}
>
redirect relative
</button>
</form>
<form>
<button
id="redirect-external"
formAction={() =>
redirectAction(
'https://next-data-api-endpoint.vercel.app/api/random?page'
)
}
>
redirect external
</button>
</form>
<form>
<button
id="redirect-absolute"
formAction={() =>
redirectAction(location.origin + '/redirect-target')
}
>
redirect internal with domain
</button>
</form>
</div>
)
}

View File

@@ -0,0 +1,3 @@
export { default } from '../node/page'
export const runtime = 'edge'

View File

@@ -0,0 +1,28 @@
'use client'
import { useActionState } from 'react'
export function Form({
echoAction,
}: {
echoAction: (value: string) => Promise<string>
}) {
let [result, formAction] = useActionState(
() => echoAction(new Array(100000).fill('あ').join('')),
null
)
let aCount = result ? result.match(/あ/g)!.length : 0
return (
<form action={formAction}>
{result && (
<p>
Server responded with {aCount} characters and{' '}
{result.length - aCount} <EFBFBD> characters.
</p>
)}
<button>Submit</button>
</form>
)
}

View File

@@ -0,0 +1,13 @@
import { Form } from './form'
export default function Page() {
return (
<Form
echoAction={async (value) => {
'use server'
return value
}}
/>
)
}

View File

@@ -0,0 +1,15 @@
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export const action = async () => {
console.log('revalidating')
revalidatePath('/delayed-action', 'page')
return Math.random()
}
export const redirectAction = async () => {
// sleep for 500ms
await new Promise((res) => setTimeout(res, 500))
redirect('/delayed-action/node')
}

View File

@@ -0,0 +1,36 @@
'use client'
import { useContext } from 'react'
import { action, redirectAction } from './actions'
import { DataContext } from './context'
export function Button() {
const { setData } = useContext(DataContext)
const handleClick = async () => {
await new Promise((res) => setTimeout(res, 1000))
const result = await action()
setData(result)
}
const handleRedirect = async () => {
await new Promise((res) => setTimeout(res, 1000))
const result = await redirectAction()
setData(result)
}
return (
<>
<button onClick={handleClick} id="run-action">
Run Action
</button>
<button onClick={handleRedirect} id="run-action-redirect">
Run Redirect
</button>
</>
)
}

View File

@@ -0,0 +1,6 @@
import React from 'react'
export const DataContext = React.createContext<{
data: number | null
setData: (number: number) => void
}>({ data: null, setData: () => {} })

View File

@@ -0,0 +1 @@
export { default } from '../layout-edge'

View File

@@ -0,0 +1 @@
export { default } from '../../other-page'

View File

@@ -0,0 +1,15 @@
import Link from 'next/link'
import { Button } from '../button'
export default function Page() {
return (
<>
<div>
<Link href="/delayed-action/edge/other">Navigate to Other Page</Link>
</div>
<div>
<Button />
</div>
</>
)
}

View File

@@ -0,0 +1,17 @@
'use client'
export const runtime = 'edge'
import { useState } from 'react'
import { DataContext } from './context'
export default function Layout({ children }) {
const [data, setData] = useState<number | null>(null)
return (
<DataContext.Provider value={{ data, setData }}>
<div>{children}</div>
<div id="delayed-action-result">{data ?? '<null>'}</div>
</DataContext.Provider>
)
}

Some files were not shown because too many files have changed in this diff Show More