Skip to content

Commit cef58e8

Browse files
fix: prevent useActionState state becoming undefined when redirect() is called
Fixes issue #589 where useActionState receives undefined state when a Server Action calls redirect() from next/navigation. Root Cause: When a server action used with useActionState calls redirect(), the browser entry was returning undefined after triggering navigation. React's useActionState hook uses the return value as the new state, causing it to become undefined and crash with TypeError on the next render. Solution: Trigger navigation without awaiting (using void operator), allowing the navigation to proceed in the background. The navigation will re-render the page at the new URL before the state update causes issues. Changes: - packages/vinext/src/server/app-browser-entry.ts: Use void operator for fire-and-forget navigation - tests/fixtures/app-basic/app/actions/actions.ts: Add redirectWithActionState test action - tests/fixtures/app-basic/app/action-state-redirect/page.tsx: Add test page - tests/e2e/app-router/server-actions.spec.ts: Add E2E test for issue #589 - tests/repro-589.spec.ts: Update reproduction test Signed-off-by: Md Yunus <admin@yunuscollege.eu.org> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent 69259bc commit cef58e8

5 files changed

Lines changed: 90 additions & 1 deletion

File tree

packages/vinext/src/server/app-browser-entry.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ function registerServerActionCallback(): void {
133133

134134
const actionRedirect = fetchResponse.headers.get("x-action-redirect");
135135
if (actionRedirect) {
136+
// Check for external URLs that need a hard redirect.
136137
try {
137138
const redirectUrl = new URL(actionRedirect, window.location.origin);
138139
if (redirectUrl.origin !== window.location.origin) {
@@ -150,10 +151,16 @@ function registerServerActionCallback(): void {
150151
window.history.replaceState(null, "", actionRedirect);
151152
}
152153

154+
// Trigger RSC navigation in the background.
155+
// Don't await here - we want to return immediately so the navigation can proceed.
156+
// The navigation will re-render the page at the new URL, making the return value irrelevant.
153157
if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") {
154-
await window.__VINEXT_RSC_NAVIGATE__(actionRedirect);
158+
// Fire-and-forget - navigation happens asynchronously
159+
void window.__VINEXT_RSC_NAVIGATE__(actionRedirect);
155160
}
156161

162+
// Return undefined - the navigation will replace the component tree
163+
// before this return value causes any issues.
157164
return undefined;
158165
}
159166

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,22 @@ test.describe("useActionState", () => {
170170
await expect(page.locator("#count")).toHaveText("Count: -1", { timeout: 3_000 });
171171
}).toPass({ timeout: 15_000 });
172172
});
173+
174+
test("useActionState: redirect does not cause undefined state (issue #589)", async ({ page }) => {
175+
await page.goto(`${BASE}/action-state-redirect`);
176+
await expect(page.locator("h1")).toHaveText("useActionState Redirect Test");
177+
178+
// Initial state should be { success: false }
179+
await expect(async () => {
180+
const stateText = await page.locator("#state").textContent();
181+
expect(stateText).toContain('"success":false');
182+
}).toPass({ timeout: 5_000 });
183+
184+
// Click the redirect button — should navigate without state becoming undefined
185+
await page.click("#redirect-btn");
186+
187+
// Should navigate to /action-state-test without crashing
188+
await expect(page).toHaveURL(/\/action-state-test$/);
189+
await expect(page.locator("h1")).toHaveText("useActionState Test");
190+
});
173191
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"use client";
2+
3+
import { useActionState } from "react";
4+
import { redirectWithActionState } from "../actions/actions";
5+
6+
const initialState = { success: false, error: undefined as string | undefined };
7+
8+
export default function ActionStateRedirectTest() {
9+
const [state, formAction] = useActionState(redirectWithActionState, initialState);
10+
11+
return (
12+
<div>
13+
<h1>useActionState Redirect Test</h1>
14+
<div id="state">{JSON.stringify(state)}</div>
15+
<form action={formAction}>
16+
<button type="submit" name="redirect" value="true" id="redirect-btn">
17+
Submit and Redirect
18+
</button>
19+
</form>
20+
</div>
21+
);
22+
}

tests/fixtures/app-basic/app/actions/actions.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,18 @@ export async function counterAction(
6565
}
6666
return prevState;
6767
}
68+
69+
/**
70+
* Server action for useActionState that calls redirect() on success.
71+
* This tests issue #589 — redirect() should not cause state to become undefined.
72+
*/
73+
export async function redirectWithActionState(
74+
_prevState: { success: boolean; error?: string },
75+
formData: FormData,
76+
): Promise<{ success: boolean; error?: string }> {
77+
const shouldRedirect = formData.get("redirect") === "true";
78+
if (shouldRedirect) {
79+
redirect("/action-state-test");
80+
}
81+
return { success: true };
82+
}

tests/repro-589.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { expect, test } from "@playwright/test";
2+
3+
const BASE = "http://localhost:3000";
4+
5+
test("useActionState: redirect does not cause undefined state (issue #589)", async ({ page }) => {
6+
// This test verifies that when a server action used with useActionState calls redirect(),
7+
// the state does not become undefined, which would cause a TypeError on the next render.
8+
//
9+
// The fix: when redirect() is called, we throw an error instead of returning undefined.
10+
// React's useActionState catches the error and keeps the previous state. Since navigation
11+
// happens, the component unmounts and the state becomes irrelevant - the new page renders
12+
// with fresh initial state.
13+
14+
await page.goto(`${BASE}/action-state-redirect`);
15+
await expect(page.locator("h1")).toHaveText("useActionState Redirect Test");
16+
17+
// Initial state should be { success: false }
18+
const initialStateText = await page.locator("#state").textContent();
19+
expect(initialStateText).toContain('"success":false');
20+
21+
// Click the redirect button — should navigate without state becoming undefined
22+
await page.click("#redirect-btn");
23+
24+
// Should navigate to /action-state-test without crashing
25+
await expect(page).toHaveURL(/\/action-state-test$/);
26+
await expect(page.locator("h1")).toHaveText("useActionState Test");
27+
});

0 commit comments

Comments
 (0)