Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions test/e2e/app-dir/async-server-references/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
14 changes: 14 additions & 0 deletions test/e2e/app-dir/async-server-references/app/prefetch/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { LinkAccordion } from '../../components/link-accordion'

export default async function Page() {
return (
<main>
<h1>
A page that links to another page which uses an async server reference
</h1>
<LinkAccordion href="/prefetch/target-page">
/prefetch/target-page
</LinkAccordion>
</main>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use server'

await Promise.resolve() // make this an async module

export async function action() {
console.log('hello from the server!')
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { action } from './async-module-with-actions'

export default async function Page() {
return (
<main id="target-page">
<h1>A page that uses an async server reference</h1>
<form action={action}>
<button type="submit">Submit</button>
</form>
</main>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use server'

await Promise.resolve() // make this an async module

const EXPECTED_VALUE = 1

export async function runActionFromArgument(action: () => Promise<number>) {
console.log('runActionFromArgument :: running action:', action)
const result = await action()
if (result !== EXPECTED_VALUE) {
throw new Error(`Action did not return ${EXPECTED_VALUE}`)
}
}

export async function myAction(): Promise<1> {
console.log('hello from the server!')
return EXPECTED_VALUE
}
26 changes: 26 additions & 0 deletions test/e2e/app-dir/async-server-references/app/reply/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client'
import { useActionState } from 'react'
import { runActionFromArgument, myAction } from './async-module-with-actions'

export function Client() {
const [state, dispatch, isPending] = useActionState(async () => {
try {
// This will execute the action passsed as an argument,
// and throw if something goes wrong.
await runActionFromArgument(myAction)
return 'ok'
} catch (err) {
return 'error'
}
}, null)
return (
<div>
<form action={dispatch}>
<button type="submit">Submit</button>
</form>
{isPending
? 'Submitting...'
: state !== null && <div id="action-result">{state}</div>}
</div>
)
}
5 changes: 5 additions & 0 deletions test/e2e/app-dir/async-server-references/app/reply/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Client } from './client'

export default function Page() {
return <Client />
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
'use server'

import { redirect } from 'next/navigation'

await Promise.resolve() // make this an async module

export async function action() {
console.log('hello from server! redirecting...')
redirect('/use-cache/redirect-target')
}
24 changes: 24 additions & 0 deletions test/e2e/app-dir/async-server-references/app/use-cache/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { action } from './async-module-with-actions'

export default async function Page() {
return (
<main>
<h1>
A page that uses a cached component whose result contains an async
server reference
</h1>
<Cached />
</main>
)
}

async function Cached() {
'use cache'
// 'use cache' decodes and re-encodes RSC data on the server,
// so it can break if async references are not resolved correctly.
return (
<form action={action}>
<button type="submit">Submit</button>
</form>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Page() {
return <main id="redirect-target">This page is a redirect target.</main>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { nextTestSetup } from '../../../lib/e2e-utils'
import { retry } from '../../../lib/next-test-utils'
import { createRouterAct } from '../segment-cache/router-act'

describe('async server references', () => {
const { next, isNextDev } = nextTestSetup({
files: __dirname,
})

// There's no prefetching in dev.
if (!isNextDev) {
it('does not crash when navigating with a prefetch', async () => {
// This is reproducing a production error that happened with `clientSegmentCache`.
// When building the segments used by clientSegmentCache,
// we'd decode the action incorrectly, which resulted in an error being thrown inside the render.
// The error would be encoded in the RSC payload, and then thrown on the client
// when the prefetched segment was rendered during a navigation.
let act: ReturnType<typeof createRouterAct>
const browser = await next.browser('/prefetch', {
beforePageLoad(page) {
act = createRouterAct(page)
},
})

// Reveal the link to trigger a prefetch.
await act(async () => {
await browser
.elementByCss('[data-link-accordion="/prefetch/target-page"]')
.click()
}, [{ includes: 'A page that uses an async server reference' }])

// Navigate to the prefetched page.
await browser.elementByCss('a[href="/prefetch/target-page"]').click()

// We expect the navigation to not crash with "Application error: a client-side exception has occurred"
// (like it did when the bug was observed) to and display the target content.
await retry(async () => {
expect(await browser.elementByCss('main#target-page').text()).toContain(
'A page that uses an async server reference'
)
})
})
}

it('decodes async server references from a action reply correctly', async () => {
const browser = await next.browser('/reply')
await browser.elementByCss('button[type="submit"]').click()
await retry(async () => {
// If the action crashes during execution (due to incorrect argument decoding)
// Or returns the wrong value (indicating that it was resolved incorrectly),
// the form status will be "error".
// If everything works correctly, it'll be "ok".
expect(await browser.elementByCss('#action-result').text()).toBe('ok')
})
})

it('correctly serializes and decodes async server references in cache functions', async () => {
// 'use cache' decodes and re-encodes RSC data on the server,
// so it can break if async references are not resolved correctly.
// Incorrect decoding of async server references was causing it to crash during build,
// so if we built successfully, we know that it works at least partially.
// We should still verify that the server action works as expected.
const browser = await next.browser('/use-cache')

// The page should display, and the action used in the cached component should work,
// triggering a redirect when executed.
await browser.elementByCss('button[type="submit"]').click()
await retry(async () => {
expect(
await browser.elementByCss('main#redirect-target').text()
).toBeTruthy()
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client'

import Link, { type LinkProps } from 'next/link'
import { useState } from 'react'

export function LinkAccordion({
href,
children,
prefetch,
}: {
href: string
children: React.ReactNode
prefetch?: LinkProps['prefetch']
}) {
const [isVisible, setIsVisible] = useState(false)
return (
<>
<input
type="checkbox"
checked={isVisible}
onChange={() => setIsVisible(!isVisible)}
data-link-accordion={href}
/>
{isVisible ? (
<Link href={href} prefetch={prefetch}>
{children}
</Link>
) : (
<>{children} (link is hidden)</>
)}
</>
)
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/async-server-references/next.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
experimental: {
useCache: true,
clientSegmentCache: true,
},
}

export default nextConfig
Loading