diff --git a/.changeset/query-decodes-redirect-carrier.md b/.changeset/query-decodes-redirect-carrier.md new file mode 100644 index 00000000..f79c335f --- /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 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 857b5222..52fea26f 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,6 +25,11 @@ 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"; @@ -281,7 +286,28 @@ 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 + // 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 = + 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 2434ac7c..e9b53492 100644 --- a/test/query-redirect.spec.tsx +++ b/test/query-redirect.spec.tsx @@ -253,3 +253,165 @@ 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(); + }); + + 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(); + }); +});