Skip to content

query() does not decode X-Server-Function-Redirect, so a redirect() thrown inside a "use server" read only redirects during SSR #603

Description

@ryansolid

Branch: next (f7602bf), against solid next (344ed054).

Summary

A redirect() thrown or returned inside a "use server" function that is wrapped in query() redirects on a full page request but not on a client-side navigation. The read settles with the Response object as its value and the navigation completes as if the check had passed.

Why

  • The server-function transport masks redirects for scripted callers: maskRedirect copies the target into X-Server-Function-Redirect (<status> <absolute-url>) and deletes Location (solid/packages/web/server-functions/src/server.ts ~2028–2034).
  • The client transport returns such a response whole, untouched, for the integration to decode (solid/packages/web/server-functions/src/client.ts ~744–757).
  • query.ts's handleResponse only checks v.headers.get("Location") before navigating (src/data/query.ts ~268–300). With Location gone, it falls through and the Response becomes the query's value.
  • action.ts does decode the carrier (decodeRedirectHeaderValue(metadata.headers.get(REDIRECT_HEADER)), src/data/action.ts ~473), which is why the same throw redirect() works from a router action.

test/query-redirect.spec.tsx covers a thrown Response that still carries Location, which is the SSR / in-process shape, so the gap is not exercised.

Repro

// src/data/account.ts
export const requireUser = query(async () => {
	"use server";
	const userId = getRequestEvent()?.locals.userId;
	if (!userId) throw redirect("/sign-in");
	return database.customers.find(userId);
}, "require-user");

Read requireUser() from a route preload or a memo under /account.

  • Load /account signed out with a full request: 302 to /sign-in. ✅
  • Navigate to /account signed out from another route (client-side): no navigation; the memo's value is a Response. ❌

Expected

query should treat a response carrying X-Server-Function-Redirect the way action does: decode it with decodeRedirectHeaderValue, navigate softly for same-origin targets, and hold the read pending on the client.

Workaround (what the docs currently describe)

Throw the redirect in the function query wraps, with the server function inside it:

export const requireUser = query(async () => {
	const user = await getCurrentUser(); // "use server"
	if (!user) throw redirect("/sign-in");
	return user;
}, "require-user");

The docs' Protected routes guide documents this shape with a caution explaining the limitation; once query decodes the carrier, that caution can be removed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions