diff --git a/.changeset/soft-cases-repeat.md b/.changeset/soft-cases-repeat.md new file mode 100644 index 000000000..5a3c903c0 --- /dev/null +++ b/.changeset/soft-cases-repeat.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Fix actions returning `json()` or `reload()` leaving no-JS form submissions stranded on the `/_server` endpoint. These responses carry a value rather than a destination, so the redirect issued for progressive-enhancement submissions had no `Location` header. It now falls back to the submitting page, and the response value is unwrapped into the flash cookie so `useSubmission().result` matches the JS path. diff --git a/apps/tests/src/e2e/no-js-action.test.ts b/apps/tests/src/e2e/no-js-action.test.ts new file mode 100644 index 000000000..bb6fc2643 --- /dev/null +++ b/apps/tests/src/e2e/no-js-action.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; + +test.describe("actions without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("returning json() stays on the submitting page and keeps the result", async ({ page }) => { + await page.goto("/no-js-action"); + await page.locator("#submit-json").click(); + + await expect(page).toHaveURL(/\/no-js-action$/); + await expect(page.locator("#json-result")).toHaveText('{"received":"from-json"}'); + }); + + test("returning reload() stays on the submitting page", async ({ page }) => { + await page.goto("/no-js-action"); + await page.locator("#submit-reload").click(); + + await expect(page).toHaveURL(/\/no-js-action$/); + }); + + test("returning redirect() follows the given location", async ({ page }) => { + await page.goto("/no-js-action"); + await page.locator("#submit-redirect").click(); + + await expect(page).toHaveURL(/\/no-js-action\?redirected=1$/); + }); + + test("returning a plain value stays on the submitting page and keeps the result", async ({ + page, + }) => { + await page.goto("/no-js-action"); + await page.locator("#submit-plain").click(); + + await expect(page).toHaveURL(/\/no-js-action$/); + await expect(page.locator("#plain-result")).toHaveText('{"received":"from-plain"}'); + }); +}); diff --git a/apps/tests/src/routes/no-js-action.tsx b/apps/tests/src/routes/no-js-action.tsx new file mode 100644 index 000000000..da4665ae3 --- /dev/null +++ b/apps/tests/src/routes/no-js-action.tsx @@ -0,0 +1,55 @@ +import { action, json, redirect, reload, useSubmission } from "@solidjs/router"; + +const jsonAction = action(async (form: FormData) => { + "use server"; + return json({ received: form.get("value") }); +}, "no-js-json-action"); + +const reloadAction = action(async (_form: FormData) => { + "use server"; + return reload(); +}, "no-js-reload-action"); + +const redirectAction = action(async (_form: FormData) => { + "use server"; + return redirect("/no-js-action?redirected=1"); +}, "no-js-redirect-action"); + +const plainAction = action(async (form: FormData) => { + "use server"; + return { received: form.get("value") }; +}, "no-js-plain-action"); + +export default function NoJsAction() { + const jsonSubmission = useSubmission(jsonAction); + const plainSubmission = useSubmission(plainAction); + + return ( +
+
+ + +
+
+ +
+
+ +
+
+ + +
+ {JSON.stringify(jsonSubmission.result ?? null)} + {JSON.stringify(plainSubmission.result ?? null)} +
+ ); +} diff --git a/packages/start/src/fns/handler.ts b/packages/start/src/fns/handler.ts index 7c308edb2..470992e76 100644 --- a/packages/start/src/fns/handler.ts +++ b/packages/start/src/fns/handler.ts @@ -111,7 +111,7 @@ export async function handleServerFunction(h3Event: H3Event) { } // handle no JS success case - if (!instance) return handleNoJS(result, request, parsed); + if (!instance) return await handleNoJS(result, request, parsed); const body = getHeadersAndBody(result); if (body) { @@ -144,7 +144,7 @@ export async function handleServerFunction(h3Event: H3Event) { h3Event.res.headers.set("X-Error", toHeaderValue(error)); } else { - x = handleNoJS(x, request, parsed, true); + x = await handleNoJS(x, request, parsed, true); } if (instance) { const body = getHeadersAndBody(x); @@ -180,7 +180,19 @@ function toHeaderValue(value: string) { } } -function handleNoJS(result: any, request: Request, parsed: any[], thrown?: boolean) { +function getRefererLocation(request: Request, url: URL) { + const referer = request.headers.get("referer"); + try { + if (referer) return new URL(referer).toString(); + } catch { + // fall through to the app root below + } + // no usable referer (e.g. a no-referrer policy): the app root still beats + // leaving the browser sitting on the server function endpoint + return new URL(import.meta.env.BASE_URL, url.origin).toString(); +} + +async function handleNoJS(result: any, request: Request, parsed: any[], thrown?: boolean) { const url = new URL(request.url); const isError = result instanceof Error; let statusCode = 302; @@ -193,10 +205,18 @@ function handleNoJS(result: any, request: Request, parsed: any[], thrown?: boole new URL(result.headers.get("Location")!, url.origin + import.meta.env.BASE_URL).toString(), ); statusCode = getExpectedRedirectStatus(result); + } else { + // responses that carry a value rather than a destination (json(), reload()) + // still have to send the browser back to the page the form came from + headers.set("Location", getRefererLocation(request, url)); } + // the body is dropped from the redirect, so don't advertise its type + headers.delete("Content-Type"); + // mirror the JS path: the flash cookie carries the value, not the Response + result = (result as any).customBody ? await (result as any).customBody() : null; } else headers = new Headers({ - Location: new URL(request.headers.get("referer")!).toString(), + Location: getRefererLocation(request, url), }); if (result) { headers.append( @@ -207,7 +227,9 @@ function handleNoJS(result: any, request: Request, parsed: any[], thrown?: boole result: isError ? result.message : result, thrown: thrown, error: isError, - input: [...parsed.slice(0, -1), [...parsed[parsed.length - 1].entries()]], + input: parsed.length + ? [...parsed.slice(0, -1), [...parsed[parsed.length - 1].entries()]] + : [], }), )}; Secure; HttpOnly;`, );