Skip to content
Draft
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/adopt-path-based-server-function-addressing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/router": patch
---

Adopt path-based server function addressing (solidjs/solid#3076). Action urls are now `<endpoint>/<id>[?args=...]` — the id in the path, bound arguments staying in the query. The generic form-action fallback reads the id back through the runtime's `parseServerFunctionUrl` instead of parsing `?id=` by hand, so the router no longer hard-codes the addressing scheme. Requires `@solidjs/web` newer than 2.0.0-rc.3.
22 changes: 13 additions & 9 deletions src/data/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {
createServerReference,
decodeResponsePayload,
parseServerFunctionUrl,

Check failure on line 6 in src/data/action.ts

View workflow job for this annotation

GitHub Actions / Check dist types

'"@solidjs/web/server-functions"' has no exported member named 'parseServerFunctionUrl'. Did you mean 'isServerFunction'?
subscribeFlightData
} from "@solidjs/web/server-functions";
// The explicit /server specifier is safe here: the only call site is
Expand Down Expand Up @@ -104,8 +105,9 @@
throw new Error("Only POST forms are supported for Actions");
// A registry miss on a server-action url is a direct bind whose module
// never loaded client-side (server components): the url is self-describing
// (`?id`, bound `?args`), so a generic invocation is synthesized from it —
// delegation alone is sufficient, the no-JS path stays a no-JS fallback.
// (the id in the path, bound `?args` in the query), so a generic invocation
// is synthesized from it — delegation alone is sufficient, the no-JS path
// stays a no-JS fallback.
// Client-only actions (`https://action/`) are their module's JS by
// definition, so a miss there falls through to native submission.
const handler = actions.get(actionRef) || (serverAction && createServerFormAction(actionRef));
Expand All @@ -123,18 +125,19 @@

/**
* Synthesizes a router action for a server-rendered action url. The url
* carries everything an invocation needs — the function id and any bound
* `.with()` arguments (plain JSON in `?args`, which the server prepends for
* natural-encoding bodies exactly as it does for no-JS posts) — so the
* FormData is posted to it verbatim through the server-function transport:
* carries everything an invocation needs — the function id in the path
* (`<endpoint>/<id>`) and any bound `.with()` arguments (plain JSON in
* `?args`, which the server prepends for natural-encoding bodies exactly as
* it does for no-JS posts) — so the FormData is posted to it verbatim
* through the server-function transport:
* submissions, `aria-busy`, redirects, revalidation, and single-flight all
* flow through the normal action machinery. Registered under the url, so
* repeat submits reuse it (and a later real registration overrides it).
*/
function createServerFormAction(
url: string
): Action<[FormData | URLSearchParams], unknown> | undefined {
const id = new URL(url, mockBase).searchParams.get("id");
const id = parseServerFunctionUrl(url);
if (!id) return undefined;
// typecheck resolves the server half of the dual module; this path only
// runs in the browser, where the client transport's signature applies
Expand Down Expand Up @@ -166,8 +169,9 @@
data: FormData
) {
const handler = actions.get(url) || createServerFormAction(url);
// no `?id` — not the server function convention; nothing can run it,
// resubmit natively (submit() bypasses the delegated handler)
// not an address (`<endpoint>/<id>`) — not the server function convention;
// nothing can run it, resubmit natively (submit() bypasses the delegated
// handler)
if (!handler) return form.submit();
handler.call(
{ r: router, f: form },
Expand Down
32 changes: 18 additions & 14 deletions test/data/action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,9 +769,10 @@ describe("handleFormAction", () => {

// The generic fallback: a server-action url with no registered handler is a
// direct bind whose module never loaded client-side (server components). The
// url is self-describing (`?id`, bound `?args`), so the router synthesizes an
// invocation from it and posts the form data through the server-function
// transport — delegation alone is sufficient, no-JS stays a no-JS fallback.
// url is self-describing (the id in the path, bound `?args` in the query), so
// the router synthesizes an invocation from it and posts the form data
// through the server-function transport — delegation alone is sufficient,
// no-JS stays a no-JS fallback.
describe("generic server actions", () => {
const ACTION_BASE = "/_server";
let originalFormData: any;
Expand Down Expand Up @@ -811,7 +812,7 @@ describe("generic server actions", () => {
}) as any;

test("synthesizes an action for an unregistered server-action url", async () => {
const ref = "/_server?id=echo%230";
const ref = "/_server/echo%230";
const form = createServerForm(ref);
const event = createSubmitEvent(form);
const navigate = vi.fn();
Expand All @@ -820,12 +821,13 @@ describe("generic server actions", () => {
handleFormAction(event, mockRouterContext, ACTION_BASE);

expect(event.preventDefault).toHaveBeenCalled();
// posted to the attribute url verbatim, as a server-function call
// posted to the attribute url verbatim, as a server-function call —
// the id travels in the path, nowhere else (no addressing header)
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(ref);
expect(init.method).toBe("POST");
expect(init.headers["X-Server-Function-Id"]).toBe("echo#0");
expect(init.headers["X-Server-Function-Id"]).toBeUndefined();
expect(init.headers["X-Server-Function-Instance"]).toBeDefined();
// response metadata falls through the normal action pipeline
await vi.waitFor(() => expect(navigate).toHaveBeenCalled());
Expand All @@ -835,7 +837,7 @@ describe("generic server actions", () => {
});

test("keeps bound `?args` in the posted url", async () => {
const ref = "/_server?id=bound%230&args=%5B7%5D";
const ref = "/_server/bound%230?args=%5B7%5D";
const form = createServerForm(ref);

handleFormAction(createSubmitEvent(form), mockRouterContext, ACTION_BASE);
Expand All @@ -845,7 +847,7 @@ describe("generic server actions", () => {
});

test("a registered action takes precedence over synthesis", () => {
const ref = "/_server?id=real%230";
const ref = "/_server/real%230";
const mockActionFn = vi.fn();
actions.set(ref, { call: mockActionFn } as any);
const form = createServerForm(ref);
Expand All @@ -856,8 +858,10 @@ describe("generic server actions", () => {
expect(fetchMock).not.toHaveBeenCalled();
});

test("falls through to native submission when the url has no id", () => {
const form = createServerForm("/_server/some-legacy-endpoint");
test("falls through to native submission when the url is not an address", () => {
// more than the one segment an address gives meaning to — the runtime
// answers null rather than matching on a prefix
const form = createServerForm("/_server/some/legacy-endpoint");
const event = createSubmitEvent(form);

handleFormAction(event, mockRouterContext, ACTION_BASE);
Expand All @@ -867,7 +871,7 @@ describe("generic server actions", () => {
});

test("submitServerForm runs the same generic path from the lazy fallback", async () => {
const ref = "/_server?id=lazy%230";
const ref = "/_server/lazy%230";
const form = createServerForm(ref);

submitServerForm(mockRouterContext, ref, form as any, {} as any);
Expand All @@ -877,10 +881,10 @@ describe("generic server actions", () => {
expect(actions.has(ref)).toBe(true);
});

test("submitServerForm resubmits natively when the url has no id", () => {
const form = createServerForm("/_server/no-id-here");
test("submitServerForm resubmits natively when the url is not an address", () => {
const form = createServerForm("/_server/no/address-here");

submitServerForm(mockRouterContext, "/_server/no-id-here", form as any, {} as any);
submitServerForm(mockRouterContext, "/_server/no/address-here", form as any, {} as any);

expect(form.submit).toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
Expand Down
10 changes: 5 additions & 5 deletions test/data/events.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,14 +606,14 @@ describe("form submit lazy fallback", () => {
vi.doMock("../../src/data/serverForms.js", () => ({ submitServerForm }));
mount();

const event = createSubmitEvent({ action: "/_server?id=echo%230&args=%5B7%5D" });
const event = createSubmitEvent({ action: "/_server/echo%230?args=%5B7%5D" });
submitHandler(event);

expect(event.preventDefault).toHaveBeenCalled();
await vi.waitFor(() => expect(submitServerForm).toHaveBeenCalled());
expect(submitServerForm).toHaveBeenCalledWith(
mockRouter,
"/_server?id=echo%230&args=%5B7%5D",
"/_server/echo%230?args=%5B7%5D",
event.target,
expect.anything()
);
Expand Down Expand Up @@ -642,7 +642,7 @@ describe("form submit lazy fallback", () => {
test("ignores non-POST forms", () => {
mount();

const event = createSubmitEvent({ action: "/_server?id=echo%230" }, "GET");
const event = createSubmitEvent({ action: "/_server/echo%230" }, "GET");
submitHandler(event);

expect(event.preventDefault).not.toHaveBeenCalled();
Expand All @@ -656,12 +656,12 @@ describe("form submit lazy fallback", () => {
const event = createSubmitEvent({ action: "/elsewhere" });
(event as any).submitter = {
hasAttribute: (name: string) => name === "formaction",
getAttribute: (name: string) => (name === "formaction" ? "/_server?id=other%230" : null)
getAttribute: (name: string) => (name === "formaction" ? "/_server/other%230" : null)
};
submitHandler(event);

expect(event.preventDefault).toHaveBeenCalled();
await vi.waitFor(() => expect(submitServerForm).toHaveBeenCalled());
expect(submitServerForm.mock.calls[0][1]).toBe("/_server?id=other%230");
expect(submitServerForm.mock.calls[0][1]).toBe("/_server/other%230");
});
});
2 changes: 1 addition & 1 deletion test/data/query.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ describe("query", () => {
expect(result).toBe("GET result");
expect(bodyCalled).toBe(false);
expect(seen.method).toBe("GET");
expect(seen.url).toContain("id=auto-get-0");
expect(seen.url).toContain("/_server/auto-get-0");
// the declaration lives on the wrapped reference; the original's
// metadata is untouched (copy-on-declare)
expect(getServerFunctionMetadata(serverFn)).toEqual({});
Expand Down
Loading