Skip to content

Commit 2dc4542

Browse files
authored
add unstable_isUnrecognizedActionError (#78933)
`unstable_isUnrecognizedActionError` is a new API that lets user code check if a server action call failed because the action id wasn't recognized by the server. This usually happens as a result of version skew between client and server. Example usage: ```ts try { await myServerAction(); } catch (err) { if (unstable_isUnrecognizedActionError(err)) { // The client is from a different deployment than the server. // Reloading the page will fix this mismatch. window.alert("Please refresh the page and try again"); return; } } ``` It can also be used to create a special error boundary, like we do in the tests added here. Note that this API is not a complete solution to this problem, as it doesn't allow handling failures for MPA actions (sent without JS). We might add a more complete API for this in the future to address that.
1 parent 95d7db7 commit 2dc4542

File tree

10 files changed

+62
-11
lines changed

10 files changed

+62
-11
lines changed

crates/next-custom-transforms/src/transforms/react_server_components.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,7 @@ impl ReactServerComponentValidator {
624624
"useRouter",
625625
"useServerInsertedHTML",
626626
"ServerInsertedHTMLContext",
627+
"unstable_isUnrecognizedActionError",
627628
],
628629
),
629630
("next/link", vec!["useLinkStatus"]),

crates/next-error-code-swc-plugin/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ fn is_error_class_name(name: &str) -> bool {
5454
|| name == "SerializableError"
5555
|| name == "StaticGenBailoutError"
5656
|| name == "TimeoutError"
57+
|| name == "UnrecognizedActionError"
5758
|| name == "Warning"
5859
}
5960

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -773,5 +773,6 @@
773773
"772": "FetchStrategy.PPRRuntime should never be used when `experimental.clientSegmentCache` is disabled",
774774
"773": "Missing workStore in createPrerenderParamsForClientSegment",
775775
"774": "Route %s used %s outside of a Server Component. This is not allowed.",
776-
"775": "Node.js instrumentation extensions should not be loaded in the Edge runtime."
776+
"775": "Node.js instrumentation extensions should not be loaded in the Edge runtime.",
777+
"776": "`unstable_isUnrecognizedActionError` can only be used on the client."
777778
}
393 Bytes
Binary file not shown.

packages/next/src/client/components/navigation.react-server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ class ReadonlyURLSearchParams extends URLSearchParams {
2626
}
2727
}
2828

29+
export function unstable_isUnrecognizedActionError(): boolean {
30+
throw new Error(
31+
'`unstable_isUnrecognizedActionError` can only be used on the client.'
32+
)
33+
}
34+
2935
export { redirect, permanentRedirect } from './redirect'
3036
export { RedirectType } from './redirect-error'
3137
export { notFound } from './not-found'

packages/next/src/client/components/navigation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,8 @@ export function useSelectedLayoutSegment(
272272
: selectedLayoutSegment
273273
}
274274

275+
export { unstable_isUnrecognizedActionError } from './unrecognized-action-error'
276+
275277
// Shared components APIs
276278
export {
277279
notFound,

packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
NEXT_URL,
1313
RSC_CONTENT_TYPE_HEADER,
1414
} from '../../app-router-headers'
15+
import { UnrecognizedActionError } from '../../unrecognized-action-error'
1516

1617
// TODO: Explicitly import from client.browser
1718
// eslint-disable-next-line import/no-extraneous-dependencies
@@ -114,7 +115,7 @@ async function fetchServerAction(
114115
// Handle server actions that the server didn't recognize.
115116
const unrecognizedActionHeader = res.headers.get(NEXT_ACTION_NOT_FOUND_HEADER)
116117
if (unrecognizedActionHeader === '1') {
117-
throw new Error(
118+
throw new UnrecognizedActionError(
118119
`Server Action "${actionId}" was not found on the server. \nRead more: https://nextjs.org/docs/messages/failed-to-find-server-action`
119120
)
120121
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export class UnrecognizedActionError extends Error {
2+
constructor(...args: ConstructorParameters<typeof Error>) {
3+
super(...args)
4+
this.name = 'UnrecognizedActionError'
5+
}
6+
}
7+
8+
/**
9+
* Check whether a server action call failed because the server action was not recognized by the server.
10+
* This can happen if the client and the server are not from the same deployment.
11+
*
12+
* Example usage:
13+
* ```ts
14+
* try {
15+
* await myServerAction();
16+
* } catch (err) {
17+
* if (unstable_isUnrecognizedActionError(err)) {
18+
* // The client is from a different deployment than the server.
19+
* // Reloading the page will fix this mismatch.
20+
* window.alert("Please refresh the page and try again");
21+
* return;
22+
* }
23+
* }
24+
* ```
25+
* */
26+
export function unstable_isUnrecognizedActionError(
27+
error: unknown
28+
): error is UnrecognizedActionError {
29+
return !!(
30+
error &&
31+
typeof error === 'object' &&
32+
error instanceof UnrecognizedActionError
33+
)
34+
}

test/e2e/app-dir/actions-unrecognized/app/nodejs/unrecognized-action/client.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22
import * as React from 'react'
33
import { useActionState } from 'react'
4+
import { unstable_isUnrecognizedActionError as isUnrecognizedActionError } from 'next/navigation'
45

56
export function FormWithArg<T>({
67
action,
@@ -41,12 +42,16 @@ export function Form({
4142
)
4243
}
4344

44-
export class ErrorBoundary extends React.Component<{
45+
export class UnrecognizedActionBoundary extends React.Component<{
4546
children: React.ReactNode
4647
}> {
4748
state = { error: null }
4849
static getDerivedStateFromError(error) {
49-
return { error }
50+
if (isUnrecognizedActionError(error)) {
51+
return { error }
52+
} else {
53+
throw error
54+
}
5055
}
5156
render() {
5257
if (this.state.error) {

test/e2e/app-dir/actions-unrecognized/app/nodejs/unrecognized-action/page.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react'
2-
import { FormWithArg, Form, ErrorBoundary } from './client'
2+
import { FormWithArg, Form, UnrecognizedActionBoundary } from './client'
33

44
const action = async (...args: any[]) => {
55
'use server'
@@ -14,31 +14,31 @@ export default function Page() {
1414
return (
1515
<div>
1616
<div>
17-
<ErrorBoundary>
17+
<UnrecognizedActionBoundary>
1818
<Form action={action} />
19-
</ErrorBoundary>
19+
</UnrecognizedActionBoundary>
2020
</div>
2121
<div>
22-
<ErrorBoundary>
22+
<UnrecognizedActionBoundary>
2323
<FormWithArg
2424
action={action}
2525
id="form-simple-argument"
2626
argument={{ foo: 'bar' }}
2727
>
2828
Submit client form with simple argument
2929
</FormWithArg>
30-
</ErrorBoundary>
30+
</UnrecognizedActionBoundary>
3131
</div>
3232
<div>
33-
<ErrorBoundary>
33+
<UnrecognizedActionBoundary>
3434
<FormWithArg
3535
action={action}
3636
id="form-complex-argument"
3737
argument={new Map([['foo', Promise.resolve('bar')]])}
3838
>
3939
Submit client form with complex argument
4040
</FormWithArg>
41-
</ErrorBoundary>
41+
</UnrecognizedActionBoundary>
4242
</div>
4343
</div>
4444
)

0 commit comments

Comments
 (0)