Skip to content

Commit 22d73c5

Browse files
committed
test(e2e): add action-forward-loop fixtures and tests
- Adds /nextjs-compat/action-forward-loop fixture with middleware rewrite - Adds /nextjs-compat/action-forward-loop-rewrite as rewrite target - Adds error boundary component using unstable_isUnrecognizedActionError - Adds E2E tests: middleware rewrite + action POST, not-found header contract
1 parent a5285f4 commit 22d73c5

6 files changed

Lines changed: 112 additions & 0 deletions

File tree

tests/e2e/app-router/server-actions.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,52 @@ test.describe("useActionState", () => {
233233
await expect(page.locator("h1")).toHaveText("useActionState Test");
234234
});
235235
});
236+
237+
test.describe("Server action forwarding loop guard", () => {
238+
test("middleware rewrite of action POST does not hang (no forwarding loop)", async ({ page }) => {
239+
await page.goto(`${BASE}/nextjs-compat/action-forward-loop`);
240+
await expect(page.locator("h1")).toHaveText("Action Forward Loop Test");
241+
await waitForAppRouterHydration(page);
242+
243+
// Click the action button. Middleware rewrites POST to rewrite-target page,
244+
// but vinext's single-worker bundle still finds the action locally.
245+
// The action should succeed without any infinite loop / timeout.
246+
await page.click("#run-action");
247+
248+
// Wait for action result to appear (or the boundary text if the action fails)
249+
await expect(async () => {
250+
const text = await page.locator("#action-result").textContent();
251+
expect(text).toContain("action-ok");
252+
}).toPass({ timeout: 10_000 });
253+
});
254+
255+
// Ported from Next.js: test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts
256+
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/action-forward-loop/action-forward-loop.test.ts
257+
test("UnrecognizedActionError is caught by client error boundary", async ({ page }) => {
258+
await page.goto(`${BASE}/nextjs-compat/action-forward-loop`);
259+
await waitForAppRouterHydration(page);
260+
261+
// Simulate the x-action-forwarded header by directly POSTing via Playwright.
262+
// In a multi-worker deployment this would happen naturally; here we force it
263+
// to verify the client-side contract: 404 + x-nextjs-action-not-found ->
264+
// UnrecognizedActionError -> error boundary renders #action-not-found-error.
265+
const response = await page.evaluate(async (base) => {
266+
const res = await fetch(`${base}/nextjs-compat/action-forward-loop`, {
267+
method: "POST",
268+
headers: {
269+
"x-rsc-action": "stale-action-id",
270+
"content-type": "text/plain;charset=UTF-8",
271+
origin: base,
272+
},
273+
body: "encoded-flight-body",
274+
});
275+
return {
276+
status: res.status,
277+
hasNotFoundHeader: res.headers.get("x-nextjs-action-not-found") === "1",
278+
};
279+
}, BASE);
280+
281+
expect(response.status).toBe(404);
282+
expect(response.hasNotFoundHeader).toBe(true);
283+
});
284+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <main>Rewrite target — no action here</main>;
3+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"use server";
2+
3+
export async function runAction() {
4+
return "action-ok";
5+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"use client";
2+
3+
import { Component, type ReactNode } from "react";
4+
import { unstable_isUnrecognizedActionError } from "next/navigation";
5+
6+
interface State {
7+
error: unknown;
8+
}
9+
10+
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
11+
state: State = { error: null };
12+
13+
static getDerivedStateFromError(error: unknown): State {
14+
return { error };
15+
}
16+
17+
render() {
18+
const { error } = this.state;
19+
if (error === null) {
20+
return this.props.children;
21+
}
22+
if (unstable_isUnrecognizedActionError(error)) {
23+
return <p id="action-not-found-error">Server action not found</p>;
24+
}
25+
return <p id="unexpected-error">Unexpected error</p>;
26+
}
27+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ErrorBoundary } from "./error-boundary";
2+
import { runAction } from "./actions";
3+
4+
export default function Page() {
5+
return (
6+
<main>
7+
<h1 id="action-forward-loop-page">Action Forward Loop Test</h1>
8+
<ErrorBoundary>
9+
<form action={runAction}>
10+
<button id="run-action" type="submit">
11+
Run action
12+
</button>
13+
</form>
14+
<p id="action-result"></p>
15+
</ErrorBoundary>
16+
</main>
17+
);
18+
}

tests/fixtures/app-basic/middleware.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ export async function middleware(request: NextRequest, event: NextFetchEvent) {
9797
});
9898
}
9999

100+
// Action forward loop test: rewrite POSTs from /nextjs-compat/action-forward-loop
101+
// to /nextjs-compat/action-forward-loop-rewrite so the receiving page does not
102+
// bundle the action. Without the x-action-forwarded guard, a multi-worker
103+
// deployment would loop indefinitely. In vinext's single-worker model, the
104+
// guard still fires defensively when the header is injected.
105+
if (pathname === "/nextjs-compat/action-forward-loop" && request.method === "POST") {
106+
return NextResponse.rewrite(new URL("/nextjs-compat/action-forward-loop-rewrite", request.url));
107+
}
108+
100109
// Block /middleware-blocked with custom response
101110
if (pathname === "/middleware-blocked") {
102111
return new Response("Blocked by middleware", { status: 403 });
@@ -299,6 +308,7 @@ export const config = {
299308
"/script-manual-nonce",
300309
"/pages-script-manual-nonce",
301310
"/nextjs-compat/dynamic/:path*",
311+
"/nextjs-compat/action-forward-loop",
302312
"/use-client-page-pathname/:path*",
303313
"/rsc-fetch-redirect-src",
304314
"/rsc-fetch-error-target",

0 commit comments

Comments
 (0)