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
5 changes: 5 additions & 0 deletions .changeset/soft-cases-repeat.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions apps/tests/src/e2e/no-js-action.test.ts
Original file line number Diff line number Diff line change
@@ -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"}');
});
});
55 changes: 55 additions & 0 deletions apps/tests/src/routes/no-js-action.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<form action={jsonAction} method="post">
<input type="hidden" name="value" value="from-json" />
<button id="submit-json" type="submit">
json()
</button>
</form>
<form action={reloadAction} method="post">
<button id="submit-reload" type="submit">
reload()
</button>
</form>
<form action={redirectAction} method="post">
<button id="submit-redirect" type="submit">
redirect()
</button>
</form>
<form action={plainAction} method="post">
<input type="hidden" name="value" value="from-plain" />
<button id="submit-plain" type="submit">
plain
</button>
</form>
<span id="json-result">{JSON.stringify(jsonSubmission.result ?? null)}</span>
<span id="plain-result">{JSON.stringify(plainSubmission.result ?? null)}</span>
</main>
);
}
32 changes: 27 additions & 5 deletions packages/start/src/fns/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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;`,
);
Expand Down
Loading