diff --git a/.changeset/adopt-path-based-server-function-addressing.md b/.changeset/adopt-path-based-server-function-addressing.md new file mode 100644 index 00000000..1130f585 --- /dev/null +++ b/.changeset/adopt-path-based-server-function-addressing.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +Adopt path-based server function addressing (solidjs/solid#3076). Action urls are now `/[?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. diff --git a/src/data/action.ts b/src/data/action.ts index 45201367..b0a18bcd 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -3,6 +3,7 @@ import { isResponseEnvelope, isServer, REVALIDATE_HEADER, type JSX } from "@soli import { createServerReference, decodeResponsePayload, + parseServerFunctionUrl, subscribeFlightData } from "@solidjs/web/server-functions"; // The explicit /server specifier is safe here: the only call site is @@ -104,8 +105,9 @@ export function handleFormAction(evt: SubmitEvent, router: RouterContext, action 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)); @@ -123,10 +125,11 @@ export function handleFormAction(evt: SubmitEvent, router: RouterContext, action /** * 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 + * (`/`) 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). @@ -134,7 +137,7 @@ export function handleFormAction(evt: SubmitEvent, router: RouterContext, action 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 @@ -166,8 +169,9 @@ export function submitServerForm( 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 (`/`) — 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 }, diff --git a/test/data/action.spec.ts b/test/data/action.spec.ts index a8d5ac64..ec7911e0 100644 --- a/test/data/action.spec.ts +++ b/test/data/action.spec.ts @@ -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; @@ -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(); @@ -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()); @@ -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); @@ -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); @@ -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); @@ -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); @@ -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(); diff --git a/test/data/events.spec.ts b/test/data/events.spec.ts index 6b10144c..2cbaaacf 100644 --- a/test/data/events.spec.ts +++ b/test/data/events.spec.ts @@ -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() ); @@ -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(); @@ -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"); }); }); diff --git a/test/data/query.spec.ts b/test/data/query.spec.ts index afd8b2d0..6044704a 100644 --- a/test/data/query.spec.ts +++ b/test/data/query.spec.ts @@ -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({});