Skip to content

Commit a5c1dcd

Browse files
committed
Fix capability form redirect navigation
1 parent df41ebe commit a5c1dcd

5 files changed

Lines changed: 39 additions & 15 deletions

File tree

.changeset/capability-form-verification-followups.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55

66
Keep capability forms and static verification consistent across framework surfaces:
77

8-
- **`@pracht/core`**: enhanced `<Form capability>` submissions now honor a clicked submitter's `formaction` and navigate redirects returned by capability middleware, matching the form's no-JavaScript behavior.
8+
- **`@pracht/core`**: enhanced `<Form capability>` submissions now honor a clicked submitter's `formaction` and follow redirects returned by capability middleware to their final browser URL, matching the form's no-JavaScript behavior.
99
- **`@pracht/cli`**: `pracht verify` rejects primitive and array `expose` values instead of reporting a complete exposed contract, and correctly describes an empty exposure object as private.

e2e/capabilities.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,29 @@ test("<Form capability> creates a note through the capability endpoint and auto-
102102
await expect(page.locator('[data-testid="notes-list"]')).toContainText("A browser note");
103103
});
104104

105+
test("<Form capability> follows endpoint redirects in the browser", async ({ page }) => {
106+
const endpointMethods: string[] = [];
107+
page.on("request", (request) => {
108+
if (new URL(request.url()).pathname === "/api/dashboard") {
109+
endpointMethods.push(request.method());
110+
}
111+
});
112+
113+
await page.goto("/notes");
114+
await expect(page.locator('[data-testid="create-note-form"]')).toHaveAttribute(
115+
"data-hydrated",
116+
"true",
117+
);
118+
await page
119+
.locator('[data-testid="create-note-form"] button')
120+
.evaluate((button) => button.setAttribute("formaction", "/api/dashboard?redirect=1"));
121+
await page.fill('[data-testid="create-note-form"] input[name="title"]', "Redirect me");
122+
await page.click('[data-testid="create-note-form"] button');
123+
124+
await expect(page).toHaveURL("/");
125+
expect(endpointMethods).toEqual(["POST"]);
126+
});
127+
105128
test("no-JS form posts hit the same capability contract and redirect back", async ({ request }) => {
106129
// The form-encoded fallback of <Form capability>: fields are coerced onto
107130
// the input schema and a successful document post 303s back to the page.
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1-
export async function POST() {
1+
import type { BaseRouteArgs } from "@pracht/core";
2+
3+
export async function POST({ request }: BaseRouteArgs) {
4+
if (new URL(request.url).searchParams.has("redirect")) {
5+
return Response.redirect(new URL("/", request.url), 302);
6+
}
27
return Response.json({ saved: true });
38
}

packages/framework/src/runtime-hooks.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -281,13 +281,9 @@ export function Form<TName extends string = string>(props: FormProps<TName>) {
281281
method: "POST",
282282
body: formData,
283283
credentials: "same-origin",
284-
redirect: "manual",
285284
});
286-
if (
287-
response.type === "opaqueredirect" ||
288-
(response.status >= 300 && response.status < 400)
289-
) {
290-
const location = response.headers.get("location");
285+
if (response.redirected || (response.status >= 300 && response.status < 400)) {
286+
const location = response.redirected ? response.url : response.headers.get("location");
291287
await navigateToClientLocation(location ?? endpoint, { reloadRouteState: true });
292288
return;
293289
}

packages/framework/test/form-validation.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -308,12 +308,12 @@ describe("<Form> validation", () => {
308308
});
309309

310310
it("navigates capability middleware redirects", async () => {
311-
fetchSpy.mockResolvedValue(
312-
new Response(null, {
313-
status: 302,
314-
headers: { location: "/login?returnTo=%2Fnotes" },
315-
}),
316-
);
311+
const redirectResponse = new Response("<h1>Login</h1>");
312+
Object.defineProperties(redirectResponse, {
313+
redirected: { value: true },
314+
url: { value: `${window.location.origin}/login?returnTo=%2Fnotes` },
315+
});
316+
fetchSpy.mockResolvedValue(redirectResponse);
317317
const navigate = vi.fn(async () => undefined);
318318
window.__PRACHT_NAVIGATE__ = navigate;
319319
const results = vi.fn();
@@ -323,7 +323,7 @@ describe("<Form> validation", () => {
323323

324324
expect(fetchSpy).toHaveBeenCalledWith(
325325
"/api/capabilities/items/save",
326-
expect.objectContaining({ redirect: "manual" }),
326+
expect.not.objectContaining({ redirect: "manual" }),
327327
);
328328
expect(navigate).toHaveBeenCalledWith("/login?returnTo=%2Fnotes", {
329329
_reloadRouteState: true,

0 commit comments

Comments
 (0)