Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/query-decodes-redirect-carrier.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 31 additions & 5 deletions src/data/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";

Expand Down Expand Up @@ -281,7 +286,28 @@ export function query<T extends (...args: any) => 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 "<status> <absolute-url>" 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
Expand Down
162 changes: 162 additions & 0 deletions test/query-redirect.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
// "<status> <absolute-url>" 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 <span>account:{name()}</span>;
};

const AccountPage = () => {
const user = createMemo(() => requireUser());
return (
<Loading fallback={<span>account-pending</span>}>
<Account value={user()} />
</Loading>
);
};

const Router = createRouter({
routes: [
{ path: "/account", component: AccountPage },
{ path: "/sign-in", component: () => <span>sign-in-page</span> }
] 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 (
<section>
<Loading fallback={<span>session-pending</span>}>
<header>user:{(session() as any)?.user}</header>
</Loading>
{props.children}
</section>
);
};

const FilePage = () => {
const files = createMemo(() => getFiles());
return (
<Loading fallback={<span>files-pending</span>}>
<span>files:{String(files())}</span>
</Loading>
);
};

const Router = createRouter({
routes: [
{ path: "/files", component: FilePage },
{ path: "/login", component: () => <span>login-page</span> }
] as const,
history: memoryHistory("/files")
});

const root = document.createElement("div");
const dispose = render(
() => <Router>{(props: ParentProps) => <Layout>{props.children}</Layout>}</Router>,
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 (
<section>
<Loading fallback={<span>session-pending</span>}>
<header>user:{(session() as any)?.user}</header>
</Loading>
{props.children}
</section>
);
};

const FilePage = () => {
const files = createMemo(() => getFiles());
return (
<Loading fallback={<span>files-pending</span>}>
<span>files:{String(files())}</span>
</Loading>
);
};

const Router = createRouter({
routes: [
{ path: "/files", component: FilePage },
{ path: "/login", component: () => <span>login-page</span> }
] as const,
history: memoryHistory("/files")
});

const root = document.createElement("div");
const dispose = render(
() => <Router>{(props: ParentProps) => <Layout>{props.children}</Layout>}</Router>,
root
);

await wait(150);
expect(root.innerHTML).toContain("login-page");
expect(sessionFetches).toBe(2);
expect(root.innerHTML).toContain("user:anonymous");
dispose();
});
});
Loading