From 7ff74bfbae5b83e237f356e94b1bad34c1555251 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 29 Aug 2026 14:30:43 -0700 Subject: [PATCH 1/2] Track the server-function data address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit solidjs/solid#3094 gives scripted calls their own address — /data/ — leaving the bare / to plain HTTP (rendered action urls, form posts). No functional router change: synthesized form actions hand the RENDERED url to createServerReference, and the transport re-addresses its own calls to the data sibling with the bound query intact. Wire-shape test expectations and the synthesis doc comment follow the split. Verified against the workspace @solidjs/web build carrying the split (both vitest configs); requires the release after 2.0.0-rc.4. Co-authored-by: Cursor --- .changeset/expect-server-function-data-address.md | 5 +++++ src/data/action.ts | 12 +++++++----- test/data/action.spec.ts | 14 +++++++++----- test/data/query.spec.ts | 3 ++- 4 files changed, 23 insertions(+), 11 deletions(-) create mode 100644 .changeset/expect-server-function-data-address.md diff --git a/.changeset/expect-server-function-data-address.md b/.changeset/expect-server-function-data-address.md new file mode 100644 index 00000000..7f253fe2 --- /dev/null +++ b/.changeset/expect-server-function-data-address.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +Track the server-function data address (solidjs/solid#3094). Scripted calls now go to `/data/` while rendered action urls stay at the bare `/`; the router needed no functional change — synthesized form actions already hand the rendered url to `createServerReference`, and the transport re-addresses its own calls — so this updates the wire-shape expectations in tests and the synthesis doc comment. Requires the @solidjs/web release that carries the data-address split. diff --git a/src/data/action.ts b/src/data/action.ts index b0a18bcd..bc791a2c 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -128,11 +128,13 @@ export function handleFormAction(evt: SubmitEvent, router: RouterContext, action * 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). + * it does for no-JS posts) — so the FormData is posted through the + * server-function transport, which addresses its own scripted calls at the + * url's data sibling (`/data/`, solidjs/solid#3094) with the + * bound query intact: submissions, `aria-busy`, redirects, revalidation, + * and single-flight all flow through the normal action machinery. + * Registered under the rendered url, so repeat submits reuse it (and a + * later real registration overrides it). */ function createServerFormAction( url: string diff --git a/test/data/action.spec.ts b/test/data/action.spec.ts index 697e72e4..97fa9eb5 100644 --- a/test/data/action.spec.ts +++ b/test/data/action.spec.ts @@ -835,11 +835,12 @@ describe("generic server actions", () => { handleFormAction(event, mockRouterContext, ACTION_BASE); expect(event.preventDefault).toHaveBeenCalled(); - // posted to the attribute url verbatim, as a server-function call — - // the id travels in the path, nowhere else (no addressing header) + // posted as a server-function call to the attribute url's data sibling + // (scripted calls have their own address, solidjs/solid#3094) — 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(url).toBe("/_server/data/echo%230"); expect(init.method).toBe("POST"); expect(init.headers["X-Server-Function-Id"]).toBeUndefined(); expect(init.headers["X-Server-Function-Instance"]).toBeDefined(); @@ -857,7 +858,9 @@ describe("generic server actions", () => { handleFormAction(createSubmitEvent(form), mockRouterContext, ACTION_BASE); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); - expect(fetchMock.mock.calls[0][0]).toBe(ref); + // the data sibling keeps the rendered url's query — bound arguments ride + // where the server reads them for natural-encoding bodies + expect(fetchMock.mock.calls[0][0]).toBe("/_server/data/bound%230?args=%5B7%5D"); }); test("a registered action takes precedence over synthesis", () => { @@ -891,7 +894,8 @@ describe("generic server actions", () => { submitServerForm(mockRouterContext, ref, form as any, {} as any); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); - expect(fetchMock.mock.calls[0][0]).toBe(ref); + expect(fetchMock.mock.calls[0][0]).toBe("/_server/data/lazy%230"); + // registration stays under the RENDERED url — what repeat submits carry expect(actions.has(ref)).toBe(true); }); diff --git a/test/data/query.spec.ts b/test/data/query.spec.ts index 6044704a..cfca9489 100644 --- a/test/data/query.spec.ts +++ b/test/data/query.spec.ts @@ -164,7 +164,8 @@ describe("query", () => { expect(result).toBe("GET result"); expect(bodyCalled).toBe(false); expect(seen.method).toBe("GET"); - expect(seen.url).toContain("/_server/auto-get-0"); + // scripted reads go to the data address (solidjs/solid#3094) + expect(seen.url).toContain("/_server/data/auto-get-0"); // the declaration lives on the wrapped reference; the original's // metadata is untouched (copy-on-declare) expect(getServerFunctionMetadata(serverFn)).toEqual({}); From 033b6f14d833f2075abac77da1791cd8b714bf52 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Sat, 29 Aug 2026 23:37:51 -0700 Subject: [PATCH 2/2] fix: navigate from the redirect carrier, not a Location string sniff Server-function redirects arrive as X-Server-Function-Redirect with the target resolved to an absolute url (solidjs/solid#3102), so the soft/hard split becomes an origin comparison: same-origin navigates softly with replace: true (the target takes the submission's place in history, as HTTP gives a form post), cross-origin hard-navigates. The old branch decided a redirect's fate by whether its target was spelled absolutely (solidjs/solid#3107). A locally-produced redirect() still navigates from its real 3xx + Location; a Location on any other status (a 201's created-at) is data and never navigates. Co-authored-by: Cursor --- .changeset/redirect-carrier-semantics.md | 5 +++ src/data/action.ts | 46 +++++++++++++++++++----- test/data/action.spec.ts | 17 +++++++-- test/data/flight-consumer.spec.ts | 26 +++++++++++--- test/data/flight-redirect.spec.ts | 24 +++++++++++-- 5 files changed, 99 insertions(+), 19 deletions(-) create mode 100644 .changeset/redirect-carrier-semantics.md diff --git a/.changeset/redirect-carrier-semantics.md b/.changeset/redirect-carrier-semantics.md new file mode 100644 index 00000000..49d79747 --- /dev/null +++ b/.changeset/redirect-carrier-semantics.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +Navigate from the redirect carrier instead of sniffing Location. Server-function redirects now arrive as `X-Server-Function-Redirect: ` (solidjs/solid#3102), so the soft/hard split is a real origin comparison — same-origin targets navigate softly with `replace: true` (the target takes the submission's place in history, matching HTTP's form-post semantics), anything else hard-navigates — never a guess from how the author spelled the target, which sent relative and absolute spellings down different navigation paths (solidjs/solid#3107). A locally-produced `redirect()` (a client-side action) still navigates from its real 3xx + Location; a `Location` on any other status is the author's data and never navigates. diff --git a/src/data/action.ts b/src/data/action.ts index bc791a2c..9f30e557 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -2,8 +2,10 @@ import { $TRACK, action as createSolidAction, createMemo, onCleanup, getOwner } import { isResponseEnvelope, isServer, REVALIDATE_HEADER, type JSX } from "@solidjs/web"; import { createServerReference, + decodeRedirectHeaderValue, decodeResponsePayload, parseServerFunctionUrl, + REDIRECT_HEADER, subscribeFlightData } from "@solidjs/web/server-functions"; // The explicit /server specifier is safe here: the only call site is @@ -414,6 +416,11 @@ async function settleActionResult(result: T | Promise | AsyncIterable) // again and wipe the freshly seeded cache). let flightApplications = 0; +// The statuses fetch follows (Fetch §2.2.3) — the set the server masks into +// the redirect carrier for scripted calls, and the set a locally-produced +// redirect() envelope wears for real. +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + /** * Registers the router as the single-flight consumer of the server function * transport. Subscribing is the opt-in: while registered, the transport @@ -434,10 +441,11 @@ export function setupFlightDataConsumer(router: RouterContext) { /** * Applies a server function response's integration metadata: `X-Revalidate` - * keys invalidate, `Location` navigates (hard for absolute urls), flight - * data seeds the query cache, and matching entries revalidate. Shared by - * the flight-data consumer and the action response path (which still sees - * metadata-bearing responses when no flight data was collected). + * keys invalidate, the redirect carrier navigates (soft when same-origin, + * hard otherwise), flight data seeds the query cache, and matching entries + * revalidate. Shared by the flight-data consumer and the action response + * path (which still sees metadata-bearing responses when no flight data was + * collected). */ function applyResponseMetadata( metadata: Response | undefined, @@ -448,12 +456,32 @@ function applyResponseMetadata( if (metadata) { if (metadata.headers.has(REVALIDATE_HEADER)) keys = metadata.headers.get(REVALIDATE_HEADER)!.split(","); - if (metadata.headers.has("Location")) { - const locationUrl = metadata.headers.get("Location") || "/"; - if (locationUrl.startsWith("http")) { - window.location.href = locationUrl; + // The carrier delivers the target RESOLVED to an absolute url + // (solidjs/solid#3102), so the soft/hard split is a real origin + // comparison — never a guess from how the author spelled the target, + // which sent `redirect("/")` and `redirect(new URL("/", url).href)` + // down different navigation paths (solidjs/solid#3107). A redirect + // produced locally (a client-side action's `redirect()`) never crossed + // the wire, so no carrier was attached: it is the real 3xx with its + // Location, resolved against the page it runs in. A `Location` on any + // other status is the author's data (a 201's created-at) and never + // navigates. Same-origin targets navigate softly under the router; + // anything else leaves the app, so the document goes with it. + // `replace` matches what HTTP gives a form post: the target takes the + // submission's place in history rather than stacking on it. + const carried = decodeRedirectHeaderValue(metadata.headers.get(REDIRECT_HEADER)); + const local = + !carried && REDIRECT_STATUSES.has(metadata.status) && metadata.headers.get("Location"); + const target = carried + ? new URL(carried.url) + : local + ? new URL(local, window.location.href) + : undefined; + if (target) { + if (target.origin === window.location.origin) { + navigate(target.pathname + target.search + target.hash, { replace: true }); } else { - navigate(locationUrl); + window.location.href = target.href; } } } diff --git a/test/data/action.spec.ts b/test/data/action.spec.ts index 97fa9eb5..468e5fda 100644 --- a/test/data/action.spec.ts +++ b/test/data/action.spec.ts @@ -222,15 +222,17 @@ describe("action", () => { const navigate = vi.fn(); mockRouterContext.navigatorFactory = () => navigate; + // a real redirect: 302 + Location, what redirect() produces — a + // Location on a non-redirect status is data and never navigates const redirectAction = action( - async () => new Response(null, { headers: { Location: "/next" } }), + async () => new Response(null, { status: 302, headers: { Location: "/next" } }), { name: "redirect-settled-test" } ).onSettled(onSettled); const boundAction = useAction(redirectAction); await boundAction(); - expect(navigate).toHaveBeenCalledWith("/next"); + expect(navigate).toHaveBeenCalledWith("/next", { replace: true }); expect(onSettled).toHaveBeenCalledTimes(1); expect(mockRouterContext.submissions[0]()).toHaveLength(0); }); @@ -799,7 +801,16 @@ describe("generic server actions", () => { return {}; }) as any; originalFetch = global.fetch; - fetchMock = vi.fn(async () => new Response(null, { headers: { Location: "/after" } })); + // the wire shape of a scripted redirect: masked 200 with the carrier + // holding the author's status and the resolved target + fetchMock = vi.fn( + async () => + new Response(null, { + headers: { + "X-Server-Function-Redirect": `302 ${new URL("/after", window.location.href).href}` + } + }) + ); global.fetch = fetchMock as any; }); diff --git a/test/data/flight-consumer.spec.ts b/test/data/flight-consumer.spec.ts index a98a9d8e..97c4371a 100644 --- a/test/data/flight-consumer.spec.ts +++ b/test/data/flight-consumer.spec.ts @@ -7,9 +7,27 @@ import { createMockRouter } from "../helpers.js"; // transport's part and deliver single-flight payloads to it directly. let consumer: FlightDataConsumer> | undefined; +// The wire shape of a scripted redirect: masked 200 with the carrier holding +// the author's status and the target resolved to an absolute url. +const carrier = (target: string, status = 302) => ({ + "X-Server-Function-Redirect": `${status} ${new URL(target, window.location.href).href}` +}); + vi.mock("@solidjs/web/server-functions", () => ({ decodeResponse: vi.fn(), decodeResponsePayload: vi.fn(), + // the redirect carrier, mirrored from the runtime (wire format: + // " ") + REDIRECT_HEADER: "X-Server-Function-Redirect", + decodeRedirectHeaderValue: (value: string | null | undefined) => { + if (typeof value !== "string") return undefined; + const at = value.indexOf(" "); + if (at < 0) return undefined; + const status = Number(value.slice(0, at)); + const url = value.slice(at + 1); + if (!Number.isInteger(status) || !url) return undefined; + return { status, url }; + }, // consumed by data/query.ts, which shares this module graph isServerFunction: () => false, getServerFunctionMetadata: () => undefined, @@ -59,9 +77,9 @@ describe("setupFlightDataConsumer", () => { setupFlightDataConsumer(router); await consumer!( { "notes[]": ["destination data"] }, - { response: new Response(null, { headers: { Location: "/notes" } }) } + { response: new Response(null, { headers: carrier("/notes") }) } ); - expect(navigate).toHaveBeenCalledWith("/notes"); + expect(navigate).toHaveBeenCalledWith("/notes", { replace: true }); expect(query.get("notes[]")).toEqual(["destination data"]); }); @@ -110,13 +128,13 @@ describe("setupFlightDataConsumer", () => { const save = async () => { await consumer!( { "layout[]": "fresh-layout" }, - { response: new Response(null, { headers: { Location: "/dash/b" } }) } + { response: new Response(null, { headers: carrier("/dash/b") }) } ); return "saved"; }; await action(save, "keyless-save").call({ r: router }); - expect(navigate).toHaveBeenCalledWith("/dash/b"); + expect(navigate).toHaveBeenCalledWith("/dash/b", { replace: true }); expect(await layout()).toBe("fresh-layout"); expect(fetchLayout).toHaveBeenCalledTimes(1); }); diff --git a/test/data/flight-redirect.spec.ts b/test/data/flight-redirect.spec.ts index 38b79aa2..dcc2e0d1 100644 --- a/test/data/flight-redirect.spec.ts +++ b/test/data/flight-redirect.spec.ts @@ -10,6 +10,18 @@ let consumer: FlightDataConsumer> | undefined; vi.mock("@solidjs/web/server-functions", () => ({ decodeResponse: vi.fn(), decodeResponsePayload: vi.fn(), + // the redirect carrier, mirrored from the runtime (wire format: + // " ") + REDIRECT_HEADER: "X-Server-Function-Redirect", + decodeRedirectHeaderValue: (value: string | null | undefined) => { + if (typeof value !== "string") return undefined; + const at = value.indexOf(" "); + if (at < 0) return undefined; + const status = Number(value.slice(0, at)); + const url = value.slice(at + 1); + if (!Number.isInteger(status) || !url) return undefined; + return { status, url }; + }, // consumed by data/query.ts, which shares this module graph isServerFunction: () => false, getServerFunctionMetadata: () => undefined, @@ -21,6 +33,12 @@ vi.mock("@solidjs/web/server-functions", () => ({ } })); +// The wire shape of a scripted redirect: masked 200 with the carrier holding +// the author's status and the target resolved to an absolute url. +const carrier = (target: string, status = 302) => ({ + "X-Server-Function-Redirect": `${status} ${new URL(target, window.location.href).href}` +}); + // Spy on the sweep: these tests pin the ordering the action layer applies to // a flight response — invalidate, seed, navigate, then sweep synchronously — // not the query cache mechanics (covered by query.spec.ts and @@ -70,7 +88,7 @@ describe("redirecting flight responses", () => { }); await consumer!( { "note[0]": { title: "fresh" } }, - { response: new Response(null, { headers: { Location: "/notes/0" } }) } + { response: new Response(null, { headers: carrier("/notes/0") }) } ); expect(sweptDuringApply).toBe(true); expect(sweepSpy).toHaveBeenCalledTimes(1); @@ -82,7 +100,7 @@ describe("redirecting flight responses", () => { { "notes[]": ["fresh"] }, { response: new Response(null, { - headers: { Location: "/notes", "X-Revalidate": "notes" } + headers: { ...carrier("/notes"), "X-Revalidate": "notes" } }) } ); @@ -109,7 +127,7 @@ describe("redirecting flight responses", () => { await consumer!( {}, { - response: new Response(null, { headers: { Location: "https://elsewhere.example/" } }) + response: new Response(null, { headers: carrier("https://elsewhere.example/") }) } ); expect(navigate).not.toHaveBeenCalled();