From 77721aa019178260077205fb4bfe1274e5b4e739 Mon Sep 17 00:00:00 2001 From: ShortForge Date: Mon, 14 Sep 2026 20:00:29 -0500 Subject: [PATCH 1/3] 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(); + }); +}); From 2bb1bcdad383b2c6d6450abbfad915b9026c17c2 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 18 Sep 2026 02:50:33 -0700 Subject: [PATCH 2/3] review: import the redirect decoder statically, as action() does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The carrier decoder is two pure bindings off a sideEffects:false entry — measured at ~200 B gz on a bundled entry, against ~7 KB for the codec the late-bound seam keeps out. Importing it statically drops the dynamic import (no extra await between the read settling and navigate(), no separate chunk in the per-module output), reuses REDIRECT_HEADER instead of a duplicated literal, and matches action.ts. Also documents redirects from queries in the README. Co-authored-by: Cursor --- .changeset/query-decodes-redirect-carrier.md | 2 +- README.md | 2 ++ src/data/query.ts | 29 ++++++++++++-------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/.changeset/query-decodes-redirect-carrier.md b/.changeset/query-decodes-redirect-carrier.md index 5378b061..f79c335f 100644 --- a/.changeset/query-decodes-redirect-carrier.md +++ b/.changeset/query-decodes-redirect-carrier.md @@ -2,4 +2,4 @@ "@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. +`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 the same pure, dependency-free binding `action()` imports, so it tree-shakes to a few hundred bytes — apps without server functions still do not ship the codec. diff --git a/README.md b/README.md index 3cd5923d..c3b9df48 100644 --- a/README.md +++ b/README.md @@ -519,6 +519,8 @@ getUser.keyFor(5); // "users[5]" Revalidate with the `revalidate` export or by setting `revalidate` keys on action responses — the whole key invalidates every entry for the query, `keyFor` invalidates one. +A query may also redirect — a guard read that throws or returns `redirect()` (from `@solidjs/web`) navigates instead of resolving: same-origin targets navigate softly with `replace`, other origins leave the document, any `revalidate` keys on the response invalidate first, and the read itself stays pending so nothing renders the redirect as data. This holds for `"use server"` queries too, where the transport carries the redirect to the client rather than letting `fetch` follow it. + ### `liveQuery` (experimental) `query`'s live sibling: a keyed query over a value-shaped stream. The function is an async iterable (typically an async generator server function) whose yields are successive **values of one logical query** — each yield is the current state, not an event — with the contract that it re-yields current state on every invocation: diff --git a/src/data/query.ts b/src/data/query.ts index b4c5e739..f8891c2b 100644 --- a/src/data/query.ts +++ b/src/data/query.ts @@ -8,10 +8,10 @@ import { } from "solid-js"; // Everything server-function-shaped comes off the CORE entry: detection // (isServerFunction/getServerFunctionMetadata, registered-symbol reads) and -// the late-bound RPC seam (getServerFunctionRPC). The server-functions -// entry itself — the fetch transport + the seroval codec behind it — is -// deliberately NOT imported here: query() is in every router app's eager -// graph, and a static import made every zero-server-function app ship +// the late-bound RPC seam (getServerFunctionRPC). The transport itself — +// the fetch RPC client + the seroval codec behind it — is deliberately NOT +// imported here: query() is in every router app's eager graph, and a static +// import of `decodeResponse` made every zero-server-function app ship // ~9 KB gz of codec it could never invoke. The transport registers itself // into the seam when a `'use server'` reference is created (compiled // output, module scope), so by the time a server function can reach @@ -25,13 +25,15 @@ import { isServerFunction, REVALIDATE_HEADER } from "@solidjs/web"; +// The redirect carrier's name and decoder are the exception: two pure, +// dependency-free bindings off a `sideEffects: false` entry, so they +// tree-shake to a few hundred bytes without dragging the codec in (the same +// bindings action.ts already imports statically). +import { decodeRedirectHeaderValue, REDIRECT_HEADER } from "@solidjs/web/server-functions"; 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) @@ -286,11 +288,14 @@ export function query any>(fn: T, name: string): Cac // 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)); + // carrier arrives RESOLVED to an absolute url, so same-origin vs + // cross-origin is a real origin comparison: same-origin folds to + // a path the soft branch below navigates under the router, any + // other origin keeps its href and the document goes with it. This + // stays synchronous on purpose — navigate runs in the same tick as + // the `Location` branch would, so the transition semantics match. + if (url === null && !isServer && v.headers.has(REDIRECT_HEADER)) { + const carried = decodeRedirectHeaderValue(v.headers.get(REDIRECT_HEADER)); if (carried) { const target = new URL(carried.url); url = From b9bd2a9966bee54880bc95e469cc73706c82b4b0 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Fri, 18 Sep 2026 03:20:29 -0700 Subject: [PATCH 3/3] test: carrier + X-Revalidate: * together sweeps every surviving query Co-authored-by: Cursor --- test/query-redirect.spec.tsx | 53 ++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/query-redirect.spec.tsx b/test/query-redirect.spec.tsx index b6287418..e9b53492 100644 --- a/test/query-redirect.spec.tsx +++ b/test/query-redirect.spec.tsx @@ -361,4 +361,57 @@ describe("redirects carried by the server-function transport (#603)", () => { expect(root.innerHTML).toContain("user:anonymous"); dispose(); }); + + test("a masked redirect with X-Revalidate: * sweeps every surviving query", async () => { + // Both protocols on one response: the carrier is decoded to the target + // and `*` (no key named) still means everything went stale — the + // surviving layout's session read revalidates inside the same navigation. + let sessionFetches = 0; + const getSession = query(async () => { + sessionFetches++; + return { user: sessionFetches === 1 ? "expired" : "anonymous" }; + }, "qr-carrier-all-session"); + const getFiles = query(async () => carriedRedirect("/login", "*"), "qr-carrier-all-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(); + }); });