Skip to content

Commit a8e07f7

Browse files
authored
refactor: make library backwards compatible with Next.js < 15.1 (#310)
Code in this PR bundles internal framework error handling (rethrown navigation errors) to make next-safe-action compatible with both newer and older Next.js versions.
1 parent 86f00f4 commit a8e07f7

File tree

11 files changed

+208
-52
lines changed

11 files changed

+208
-52
lines changed

packages/next-safe-action/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,9 @@
9191
},
9292
"peerDependencies": {
9393
"@sinclair/typebox": ">= 0.33.3",
94-
"next": ">= 15.1.0",
95-
"react": ">= 19.0.0",
96-
"react-dom": ">= 19.0.0",
94+
"next": ">= 14.0.0",
95+
"react": ">= 18.2.0",
96+
"react-dom": ">= 18.2.0",
9797
"valibot": ">= 0.36.0",
9898
"yup": ">= 1.0.0",
9999
"zod": ">= 3.0.0"

packages/next-safe-action/src/action-builder.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,13 @@ import type {
1313
StateServerCodeFn,
1414
} from "./index.types";
1515
import {
16-
DEFAULT_SERVER_ERROR_MESSAGE,
17-
isError,
1816
isForbiddenError,
1917
isFrameworkError,
2018
isNotFoundError,
2119
isRedirectError,
2220
isUnauthorizedError,
23-
winningBoolean,
24-
} from "./utils";
21+
} from "./next/errors";
22+
import { DEFAULT_SERVER_ERROR_MESSAGE, isError, winningBoolean } from "./utils";
2523
import {
2624
ActionMetadataValidationError,
2725
ActionOutputDataValidationError,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Comes from https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/lazy-dynamic/bailout-to-csr.ts
2+
3+
// This has to be a shared module which is shared between client component error boundary and dynamic component
4+
const BAILOUT_TO_CSR = "BAILOUT_TO_CLIENT_SIDE_RENDERING";
5+
6+
/** An error that should be thrown when we want to bail out to client-side rendering. */
7+
class BailoutToCSRError extends Error {
8+
public readonly digest = BAILOUT_TO_CSR;
9+
10+
constructor(public readonly reason: string) {
11+
super(`Bail out to client-side rendering: ${reason}`);
12+
}
13+
}
14+
15+
/** Checks if a passed argument is an error that is thrown if we want to bail out to client-side rendering. */
16+
export function isBailoutToCSRError(err: unknown): err is BailoutToCSRError {
17+
if (typeof err !== "object" || err === null || !("digest" in err)) {
18+
return false;
19+
}
20+
21+
return err.digest === BAILOUT_TO_CSR;
22+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Comes from https://github.com/vercel/next.js/blob/canary/packages/next/src/export/helpers/is-dynamic-usage-error.ts
2+
3+
import { isBailoutToCSRError } from "./bailout-to-csr";
4+
import { isNextRouterError } from "./router";
5+
6+
const DYNAMIC_ERROR_CODE = "DYNAMIC_SERVER_USAGE";
7+
8+
class DynamicServerError extends Error {
9+
digest: typeof DYNAMIC_ERROR_CODE = DYNAMIC_ERROR_CODE;
10+
11+
constructor(public readonly description: string) {
12+
super(`Dynamic server usage: ${description}`);
13+
}
14+
}
15+
16+
function isDynamicServerError(err: unknown): err is DynamicServerError {
17+
if (typeof err !== "object" || err === null || !("digest" in err) || typeof err.digest !== "string") {
18+
return false;
19+
}
20+
21+
return err.digest === DYNAMIC_ERROR_CODE;
22+
}
23+
24+
function isDynamicPostponeReason(reason: string) {
25+
return (
26+
reason.includes("needs to bail out of prerendering at this point because it used") &&
27+
reason.includes("Learn more: https://nextjs.org/docs/messages/ppr-caught-error")
28+
);
29+
}
30+
31+
function isDynamicPostpone(err: unknown) {
32+
if (
33+
typeof err === "object" &&
34+
err !== null &&
35+
// eslint-disable-next-line
36+
typeof (err as any).message === "string"
37+
) {
38+
// eslint-disable-next-line
39+
return isDynamicPostponeReason((err as any).message);
40+
}
41+
return false;
42+
}
43+
44+
export const isDynamicUsageError = (err: unknown) =>
45+
isDynamicServerError(err) || isBailoutToCSRError(err) || isNextRouterError(err) || isDynamicPostpone(err);
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Comes from https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/http-access-fallback/http-access-fallback.ts
2+
3+
const HTTPAccessErrorStatus = {
4+
NOT_FOUND: 404,
5+
FORBIDDEN: 403,
6+
UNAUTHORIZED: 401,
7+
};
8+
9+
const ALLOWED_CODES = new Set(Object.values(HTTPAccessErrorStatus));
10+
11+
const HTTP_ERROR_FALLBACK_ERROR_CODE = "NEXT_HTTP_ERROR_FALLBACK";
12+
13+
export type HTTPAccessFallbackError = Error & {
14+
digest: `${typeof HTTP_ERROR_FALLBACK_ERROR_CODE};${string}`;
15+
};
16+
17+
/**
18+
* Checks an error to determine if it's an error generated by
19+
* the HTTP navigation APIs `notFound()`, `forbidden()` or `unauthorized()`.
20+
*
21+
* @param error the error that may reference a HTTP access error
22+
* @returns true if the error is a HTTP access error
23+
*/
24+
export function isHTTPAccessFallbackError(error: unknown): error is HTTPAccessFallbackError {
25+
if (typeof error !== "object" || error === null || !("digest" in error) || typeof error.digest !== "string") {
26+
return false;
27+
}
28+
const [prefix, httpStatus] = error.digest.split(";");
29+
30+
return prefix === HTTP_ERROR_FALLBACK_ERROR_CODE && ALLOWED_CODES.has(Number(httpStatus));
31+
}
32+
33+
export function getAccessFallbackHTTPStatus(error: HTTPAccessFallbackError): number {
34+
const httpStatus = error.digest.split(";")[1];
35+
return Number(httpStatus);
36+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { isBailoutToCSRError } from "./bailout-to-csr";
2+
import { isDynamicUsageError } from "./dynamic-usage";
3+
import {
4+
getAccessFallbackHTTPStatus,
5+
isHTTPAccessFallbackError,
6+
type HTTPAccessFallbackError,
7+
} from "./http-access-fallback";
8+
import { isPostpone } from "./postpone";
9+
import { isNextRouterError } from "./router";
10+
11+
export function isNotFoundError(error: unknown): error is HTTPAccessFallbackError {
12+
return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 404;
13+
}
14+
15+
export function isForbiddenError(error: unknown): error is HTTPAccessFallbackError {
16+
return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 403;
17+
}
18+
19+
export function isUnauthorizedError(error: unknown): error is HTTPAccessFallbackError {
20+
return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 401;
21+
}
22+
23+
// Next.js error handling
24+
export function isFrameworkError(error: unknown): error is Error {
25+
return isNextRouterError(error) || isBailoutToCSRError(error) || isDynamicUsageError(error) || isPostpone(error);
26+
}
27+
28+
export { isRedirectError } from "./redirect";
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Comes from https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/router-utils/is-postpone.ts
2+
3+
const REACT_POSTPONE_TYPE: symbol = Symbol.for("react.postpone");
4+
5+
export function isPostpone(error: any): boolean {
6+
return (
7+
typeof error === "object" &&
8+
error !== null &&
9+
// eslint-disable-next-line
10+
error.$$typeof === REACT_POSTPONE_TYPE
11+
);
12+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Comes from: https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/redirect-error.ts
2+
3+
enum RedirectStatusCode {
4+
SeeOther = 303,
5+
TemporaryRedirect = 307,
6+
PermanentRedirect = 308,
7+
}
8+
9+
const REDIRECT_ERROR_CODE = "NEXT_REDIRECT";
10+
11+
enum RedirectType {
12+
push = "push",
13+
replace = "replace",
14+
}
15+
16+
export type RedirectError = Error & {
17+
digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${string};${RedirectStatusCode};`;
18+
};
19+
20+
/**
21+
* Checks an error to determine if it's an error generated by the
22+
* `redirect(url)` helper.
23+
*
24+
* @param error the error that may reference a redirect error
25+
* @returns true if the error is a redirect error
26+
*/
27+
export function isRedirectError(error: unknown): error is RedirectError {
28+
if (typeof error !== "object" || error === null || !("digest" in error) || typeof error.digest !== "string") {
29+
return false;
30+
}
31+
32+
const digest = error.digest.split(";");
33+
const [errorCode, type] = digest;
34+
const destination = digest.slice(2, -2).join(";");
35+
const status = digest.at(-2);
36+
37+
const statusCode = Number(status);
38+
39+
return (
40+
errorCode === REDIRECT_ERROR_CODE &&
41+
(type === "replace" || type === "push") &&
42+
typeof destination === "string" &&
43+
!isNaN(statusCode) &&
44+
statusCode in RedirectStatusCode
45+
);
46+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Comes from https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/is-next-router-error.ts
2+
3+
import { isHTTPAccessFallbackError, type HTTPAccessFallbackError } from "./http-access-fallback";
4+
import { isRedirectError, type RedirectError } from "./redirect";
5+
6+
/**
7+
* Returns true if the error is a navigation signal error. These errors are
8+
* thrown by user code to perform navigation operations and interrupt the React
9+
* render.
10+
*/
11+
export function isNextRouterError(error: unknown): error is RedirectError | HTTPAccessFallbackError {
12+
return isRedirectError(error) || isHTTPAccessFallbackError(error);
13+
}
Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,3 @@
1-
import type { HTTPAccessFallbackError } from "next/dist/client/components/http-access-fallback/http-access-fallback.js";
2-
import {
3-
getAccessFallbackHTTPStatus,
4-
isHTTPAccessFallbackError,
5-
} from "next/dist/client/components/http-access-fallback/http-access-fallback.js";
6-
import { isNextRouterError } from "next/dist/client/components/is-next-router-error.js";
7-
import { isRedirectError } from "next/dist/client/components/redirect-error.js";
8-
import { isDynamicUsageError } from "next/dist/export/helpers/is-dynamic-usage-error.js";
9-
import { isPostpone } from "next/dist/server/lib/router-utils/is-postpone.js";
10-
import { isBailoutToCSRError } from "next/dist/shared/lib/lazy-dynamic/bailout-to-csr.js";
11-
121
export const DEFAULT_SERVER_ERROR_MESSAGE = "Something went wrong while executing the operation.";
132

143
/**
@@ -23,22 +12,3 @@ export const isError = (error: unknown): error is Error => error instanceof Erro
2312
export const winningBoolean = (...args: (boolean | undefined | null)[]) => {
2413
return args.reduce((acc, v) => (typeof v === "boolean" ? v : acc), false) as boolean;
2514
};
26-
27-
// Next.js error handling
28-
export function isFrameworkError(error: unknown): error is Error {
29-
return isNextRouterError(error) || isBailoutToCSRError(error) || isDynamicUsageError(error) || isPostpone(error);
30-
}
31-
32-
export function isNotFoundError(error: unknown): error is HTTPAccessFallbackError {
33-
return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 404;
34-
}
35-
36-
export function isForbiddenError(error: unknown): error is HTTPAccessFallbackError {
37-
return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 403;
38-
}
39-
40-
export function isUnauthorizedError(error: unknown): error is HTTPAccessFallbackError {
41-
return isHTTPAccessFallbackError(error) && getAccessFallbackHTTPStatus(error) === 401;
42-
}
43-
44-
export { isRedirectError };

0 commit comments

Comments
 (0)