From 77721aa019178260077205fb4bfe1274e5b4e739 Mon Sep 17 00:00:00 2001 From: ShortForge Date: Mon, 14 Sep 2026 20:00:29 -0500 Subject: [PATCH] fix(query): follow redirects carried in X-Server-Function-Redirect (#603) --- .changeset/query-decodes-redirect-carrier.md | 5 + src/data/query.ts | 23 +++- test/query-redirect.spec.tsx | 109 +++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 .changeset/query-decodes-redirect-carrier.md diff --git a/.changeset/query-decodes-redirect-carrier.md b/.changeset/query-decodes-redirect-carrier.md new file mode 100644 index 00000000..5378b061 --- /dev/null +++ b/.changeset/query-decodes-redirect-carrier.md @@ -0,0 +1,5 @@ +--- +"@solidjs/router": patch +--- + +`query()` now follows a redirect carried in `X-Server-Function-Redirect` (#603). A `redirect()` thrown or returned inside a `"use server"` function wrapped in `query()` reaches a client-side read masked to a 200 with `Location` removed, so `query` settled with the `Response` as its value and the navigation completed as if the check had passed; it only redirected on a full page request. The carrier is decoded with the runtime's `decodeRedirectHeaderValue`, the way `action()` already does: same-origin targets navigate softly with `replace`, other origins navigate the document, `X-Revalidate` keys are honored, and the read stays pending on the client. The decoder is loaded with a dynamic import, so apps without server functions still do not ship the transport. diff --git a/src/data/query.ts b/src/data/query.ts index e7747702..b4c5e739 100644 --- a/src/data/query.ts +++ b/src/data/query.ts @@ -29,6 +29,9 @@ import { useRouter, getIntent, getInPreloadFn } from "../routing.js"; import type { CacheEntry, NarrowResponse } from "../types.js"; const LocationHeader = "Location"; +// `REDIRECT_HEADER` from @solidjs/web/server-functions, named here so the +// check below does not pull that entry into every router app's graph. +const RedirectHeader = "X-Server-Function-Redirect"; const PRELOAD_TIMEOUT = 5000; const CACHE_TIMEOUT = 180000; // When this client booted. Flight-registry entries (sharedConfig.has/load) @@ -277,7 +280,25 @@ export function query any>(fn: T, name: string): Cac } } - const url = v.headers.get(LocationHeader); + let url = v.headers.get(LocationHeader); + + // A `"use server"` redirect reaches a client-side read masked: the + // transport answers scripted callers with a 200, drops `Location` + // and carries " " in REDIRECT_HEADER instead. + // Decode it with the runtime's own reader, as action() does. The + // import is dynamic so plain-fetch apps still never ship the + // transport: a carrier only arrives where it is already loaded. + if (url === null && !isServer && v.headers.has(RedirectHeader)) { + const { decodeRedirectHeaderValue } = await import("@solidjs/web/server-functions"); + const carried = decodeRedirectHeaderValue(v.headers.get(RedirectHeader)); + if (carried) { + const target = new URL(carried.url); + url = + target.origin === window.location.origin + ? target.pathname + target.search + target.hash + : target.href; + } + } if (url !== null) { // invalidate the redirect's revalidation keys before navigating so diff --git a/test/query-redirect.spec.tsx b/test/query-redirect.spec.tsx index 8537fa85..64cdfd03 100644 --- a/test/query-redirect.spec.tsx +++ b/test/query-redirect.spec.tsx @@ -198,3 +198,112 @@ describe("redirects thrown from queries", () => { dispose(); }); }); + +// The shape a `"use server"` redirect reaches a client-side query in: the +// server-function transport masks the 3xx to 200, drops `Location`, and carries +// " " in X-Server-Function-Redirect; the client transport +// then hands that Response over whole (solidjs/solid-router#603). +const carriedRedirect = (to: string, revalidate?: string) => + new Response(null, { + status: 200, + headers: { + "X-Server-Function-Redirect": `302 ${new URL(to, window.location.href).href}`, + ...(revalidate ? { "X-Revalidate": revalidate } : {}) + } + }); + +describe("redirects carried by the server-function transport (#603)", () => { + test("a masked redirect navigates and never reaches consumers", async () => { + const observed: any[] = []; + const caught: any[] = []; + + const requireUser = query(async () => carriedRedirect("/sign-in"), "qr-carrier-user"); + + const Account = (props: { value: any }) => { + const name = createMemo(() => { + observed.push(props.value); + return props.value.name; + }); + return account:{name()}; + }; + + const AccountPage = () => { + const user = createMemo(() => requireUser()); + return ( + account-pending}> + + + ); + }; + + const Router = createRouter({ + routes: [ + { path: "/account", component: AccountPage }, + { path: "/sign-in", component: () => sign-in-page } + ] as const, + history: memoryHistory("/account") + }); + + const { root, dispose } = mount(Router, caught); + await wait(150); + + expect(root.innerHTML).toContain("sign-in-page"); + // the Response must not become the query's value + expect(observed).toEqual([]); + expect(caught).toEqual([]); + dispose(); + }); + + test("X-Revalidate keys on a masked redirect invalidate and revalidate", async () => { + let sessionFetches = 0; + const getSession = query(async () => { + sessionFetches++; + return { user: sessionFetches === 1 ? "expired" : "anonymous" }; + }, "qr-carrier-session"); + const getFiles = query( + async () => carriedRedirect("/login", getSession.key), + "qr-carrier-files" + ); + + const Layout = (props: ParentProps) => { + const session = createMemo(() => getSession()); + return ( +
+ session-pending}> +
user:{(session() as any)?.user}
+
+ {props.children} +
+ ); + }; + + const FilePage = () => { + const files = createMemo(() => getFiles()); + return ( + files-pending}> + files:{String(files())} + + ); + }; + + const Router = createRouter({ + routes: [ + { path: "/files", component: FilePage }, + { path: "/login", component: () => login-page } + ] as const, + history: memoryHistory("/files") + }); + + const root = document.createElement("div"); + const dispose = render( + () => {(props: ParentProps) => {props.children}}, + root + ); + + await wait(150); + expect(root.innerHTML).toContain("login-page"); + expect(sessionFetches).toBe(2); + expect(root.innerHTML).toContain("user:anonymous"); + dispose(); + }); +});