Skip to content
Open
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 loaded with a dynamic import, so apps without server functions still do not ship the transport.
23 changes: 22 additions & 1 deletion src/data/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -277,7 +280,25 @@ 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
// 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
Expand Down
109 changes: 109 additions & 0 deletions test/query-redirect.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
// "<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();
});
});