Skip to content
Merged
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: 8 additions & 8 deletions packages/vinext/src/server/app-browser-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,27 +133,27 @@ function registerServerActionCallback(): void {

const actionRedirect = fetchResponse.headers.get("x-action-redirect");
if (actionRedirect) {
// Check for external URLs that need a hard redirect.
try {
const redirectUrl = new URL(actionRedirect, window.location.origin);
if (redirectUrl.origin !== window.location.origin) {
window.location.href = actionRedirect;
return undefined;
}
} catch {
// Fall through to client-side navigation if URL parsing fails.
// Fall through to hard redirect below if URL parsing fails.
}

// Use hard redirect for all action redirects because vinext's server
// currently returns an empty body for redirect responses. RSC navigation
// requires a valid RSC payload. This is a known parity gap with Next.js,
// which pre-renders the redirect target's RSC payload.
const redirectType = fetchResponse.headers.get("x-action-redirect-type") ?? "replace";
if (redirectType === "push") {
window.history.pushState(null, "", actionRedirect);
window.location.assign(actionRedirect);
} else {
window.history.replaceState(null, "", actionRedirect);
window.location.replace(actionRedirect);
}

if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") {
await window.__VINEXT_RSC_NAVIGATE__(actionRedirect);
}

return undefined;
}

Expand Down
19 changes: 19 additions & 0 deletions tests/e2e/app-router/server-actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,23 @@ test.describe("useActionState", () => {
await expect(page.locator("#count")).toHaveText("Count: -1", { timeout: 3_000 });
}).toPass({ timeout: 15_000 });
});

test("useActionState: redirect does not cause undefined state (issue #589)", async ({ page }) => {
await page.goto(`${BASE}/action-state-redirect`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing waitForHydration(page) call. Every other test in this file that interacts with client-side JavaScript calls waitForHydration after goto. The useActionState form requires JavaScript hydration to function — clicking the button before hydration completes means the form will do a native submit (full page POST) instead of the client-side server action fetch.

This may pass by accident if hydration completes during the toPass polling, but it should be explicit:

Suggested change
await page.goto(`${BASE}/action-state-redirect`);
await page.goto(`${BASE}/action-state-redirect`);
await expect(page.locator("h1")).toHaveText("useActionState Redirect Test");
await waitForHydration(page);

await expect(page.locator("h1")).toHaveText("useActionState Redirect Test");
await waitForHydration(page);

// Initial state should be { success: false }
await expect(async () => {
const stateText = await page.locator("#state").textContent();
expect(stateText).toContain('"success":false');
}).toPass({ timeout: 5_000 });

// Click the redirect button — should navigate without state becoming undefined
await page.click("#redirect-btn");

// Should navigate to /action-state-test without crashing
await expect(page).toHaveURL(/\/action-state-test$/);
await expect(page.locator("h1")).toHaveText("useActionState Test");
});
});
22 changes: 22 additions & 0 deletions tests/fixtures/app-basic/app/action-state-redirect/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use client";

import { useActionState } from "react";
import { redirectWithActionState } from "../actions/actions";

const initialState = { success: false, error: undefined as string | undefined };

export default function ActionStateRedirectTest() {
const [state, formAction] = useActionState(redirectWithActionState, initialState);

return (
<div>
<h1>useActionState Redirect Test</h1>
<div id="state">{JSON.stringify(state)}</div>
<form action={formAction}>
<button type="submit" name="redirect" value="true" id="redirect-btn">
Submit and Redirect
</button>
</form>
</div>
);
}
15 changes: 15 additions & 0 deletions tests/fixtures/app-basic/app/actions/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,18 @@ export async function counterAction(
}
return prevState;
}

/**
* Server action for useActionState that calls redirect() on success.
* This tests issue #589 — redirect() should not cause state to become undefined.
*/
export async function redirectWithActionState(
_prevState: { success: boolean; error?: string },
formData: FormData,
): Promise<{ success: boolean; error?: string }> {
const shouldRedirect = formData.get("redirect") === "true";
if (shouldRedirect) {
redirect("/action-state-test");
}
return { success: true };
}
Loading