Skip to content
Draft
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/expect-server-function-data-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/router": patch
---

Track the server-function data address (solidjs/solid#3094). Scripted calls now go to `<endpoint>/data/<id>` while rendered action urls stay at the bare `<endpoint>/<id>`; the router needed no functional change — synthesized form actions already hand the rendered url to `createServerReference`, and the transport re-addresses its own calls — so this updates the wire-shape expectations in tests and the synthesis doc comment. Requires the @solidjs/web release that carries the data-address split.
5 changes: 5 additions & 0 deletions .changeset/redirect-carrier-semantics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/router": patch
---

Navigate from the redirect carrier instead of sniffing Location. Server-function redirects now arrive as `X-Server-Function-Redirect: <status> <resolved-url>` (solidjs/solid#3102), so the soft/hard split is a real origin comparison — same-origin targets navigate softly with `replace: true` (the target takes the submission's place in history, matching HTTP's form-post semantics), anything else hard-navigates — never a guess from how the author spelled the target, which sent relative and absolute spellings down different navigation paths (solidjs/solid#3107). A locally-produced `redirect()` (a client-side action) still navigates from its real 3xx + Location; a `Location` on any other status is the author's data and never navigates.
58 changes: 44 additions & 14 deletions src/data/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import { isResponseEnvelope, isServer, REVALIDATE_HEADER, type JSX } from "@solidjs/web";
import {
createServerReference,
decodeRedirectHeaderValue,

Check failure on line 5 in src/data/action.ts

View workflow job for this annotation

GitHub Actions / Check dist types

'"@solidjs/web/server-functions"' has no exported member named 'decodeRedirectHeaderValue'. Did you mean 'decodeErrorHeaderValue'?
decodeResponsePayload,
parseServerFunctionUrl,
REDIRECT_HEADER,

Check failure on line 8 in src/data/action.ts

View workflow job for this annotation

GitHub Actions / Check dist types

Module '"@solidjs/web/server-functions"' has no exported member 'REDIRECT_HEADER'.
subscribeFlightData
} from "@solidjs/web/server-functions";
// The explicit /server specifier is safe here: the only call site is
Expand Down Expand Up @@ -128,11 +130,13 @@
* carries everything an invocation needs — the function id in the path
* (`<endpoint>/<id>`) and any bound `.with()` arguments (plain JSON in
* `?args`, which the server prepends for natural-encoding bodies exactly as
* it does for no-JS posts) — so the FormData is posted to it verbatim
* through the server-function transport:
* submissions, `aria-busy`, redirects, revalidation, and single-flight all
* flow through the normal action machinery. Registered under the url, so
* repeat submits reuse it (and a later real registration overrides it).
* it does for no-JS posts) — so the FormData is posted through the
* server-function transport, which addresses its own scripted calls at the
* url's data sibling (`<endpoint>/data/<id>`, solidjs/solid#3094) with the
* bound query intact: submissions, `aria-busy`, redirects, revalidation,
* and single-flight all flow through the normal action machinery.
* Registered under the rendered url, so repeat submits reuse it (and a
* later real registration overrides it).
*/
function createServerFormAction(
url: string
Expand Down Expand Up @@ -412,6 +416,11 @@
// again and wipe the freshly seeded cache).
let flightApplications = 0;

// The statuses fetch follows (Fetch §2.2.3) — the set the server masks into
// the redirect carrier for scripted calls, and the set a locally-produced
// redirect() envelope wears for real.
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);

/**
* Registers the router as the single-flight consumer of the server function
* transport. Subscribing is the opt-in: while registered, the transport
Expand All @@ -432,10 +441,11 @@

/**
* Applies a server function response's integration metadata: `X-Revalidate`
* keys invalidate, `Location` navigates (hard for absolute urls), flight
* data seeds the query cache, and matching entries revalidate. Shared by
* the flight-data consumer and the action response path (which still sees
* metadata-bearing responses when no flight data was collected).
* keys invalidate, the redirect carrier navigates (soft when same-origin,
* hard otherwise), flight data seeds the query cache, and matching entries
* revalidate. Shared by the flight-data consumer and the action response
* path (which still sees metadata-bearing responses when no flight data was
* collected).
*/
function applyResponseMetadata(
metadata: Response | undefined,
Expand All @@ -446,12 +456,32 @@
if (metadata) {
if (metadata.headers.has(REVALIDATE_HEADER))
keys = metadata.headers.get(REVALIDATE_HEADER)!.split(",");
if (metadata.headers.has("Location")) {
const locationUrl = metadata.headers.get("Location") || "/";
if (locationUrl.startsWith("http")) {
window.location.href = locationUrl;
// The carrier delivers the target RESOLVED to an absolute url
// (solidjs/solid#3102), so the soft/hard split is a real origin
// comparison — never a guess from how the author spelled the target,
// which sent `redirect("/")` and `redirect(new URL("/", url).href)`
// down different navigation paths (solidjs/solid#3107). A redirect
// produced locally (a client-side action's `redirect()`) never crossed
// the wire, so no carrier was attached: it is the real 3xx with its
// Location, resolved against the page it runs in. A `Location` on any
// other status is the author's data (a 201's created-at) and never
// navigates. Same-origin targets navigate softly under the router;
// anything else leaves the app, so the document goes with it.
// `replace` matches what HTTP gives a form post: the target takes the
// submission's place in history rather than stacking on it.
const carried = decodeRedirectHeaderValue(metadata.headers.get(REDIRECT_HEADER));
const local =
!carried && REDIRECT_STATUSES.has(metadata.status) && metadata.headers.get("Location");
const target = carried
? new URL(carried.url)
: local
? new URL(local, window.location.href)
: undefined;
if (target) {
if (target.origin === window.location.origin) {
navigate(target.pathname + target.search + target.hash, { replace: true });
} else {
navigate(locationUrl);
window.location.href = target.href;
}
}
}
Expand Down
31 changes: 23 additions & 8 deletions test/data/action.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,15 +222,17 @@ describe("action", () => {
const navigate = vi.fn();
mockRouterContext.navigatorFactory = () => navigate;

// a real redirect: 302 + Location, what redirect() produces — a
// Location on a non-redirect status is data and never navigates
const redirectAction = action(
async () => new Response(null, { headers: { Location: "/next" } }),
async () => new Response(null, { status: 302, headers: { Location: "/next" } }),
{ name: "redirect-settled-test" }
).onSettled(onSettled);

const boundAction = useAction(redirectAction);
await boundAction();

expect(navigate).toHaveBeenCalledWith("/next");
expect(navigate).toHaveBeenCalledWith("/next", { replace: true });
expect(onSettled).toHaveBeenCalledTimes(1);
expect(mockRouterContext.submissions[0]()).toHaveLength(0);
});
Expand Down Expand Up @@ -799,7 +801,16 @@ describe("generic server actions", () => {
return {};
}) as any;
originalFetch = global.fetch;
fetchMock = vi.fn(async () => new Response(null, { headers: { Location: "/after" } }));
// the wire shape of a scripted redirect: masked 200 with the carrier
// holding the author's status and the resolved target
fetchMock = vi.fn(
async () =>
new Response(null, {
headers: {
"X-Server-Function-Redirect": `302 ${new URL("/after", window.location.href).href}`
}
})
);
global.fetch = fetchMock as any;
});

Expand Down Expand Up @@ -835,11 +846,12 @@ describe("generic server actions", () => {
handleFormAction(event, mockRouterContext, ACTION_BASE);

expect(event.preventDefault).toHaveBeenCalled();
// posted to the attribute url verbatim, as a server-function call —
// the id travels in the path, nowhere else (no addressing header)
// posted as a server-function call to the attribute url's data sibling
// (scripted calls have their own address, solidjs/solid#3094) — the id
// travels in the path, nowhere else (no addressing header)
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(ref);
expect(url).toBe("/_server/data/echo%230");
expect(init.method).toBe("POST");
expect(init.headers["X-Server-Function-Id"]).toBeUndefined();
expect(init.headers["X-Server-Function-Instance"]).toBeDefined();
Expand All @@ -857,7 +869,9 @@ describe("generic server actions", () => {
handleFormAction(createSubmitEvent(form), mockRouterContext, ACTION_BASE);

await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(fetchMock.mock.calls[0][0]).toBe(ref);
// the data sibling keeps the rendered url's query — bound arguments ride
// where the server reads them for natural-encoding bodies
expect(fetchMock.mock.calls[0][0]).toBe("/_server/data/bound%230?args=%5B7%5D");
});

test("a registered action takes precedence over synthesis", () => {
Expand Down Expand Up @@ -891,7 +905,8 @@ describe("generic server actions", () => {
submitServerForm(mockRouterContext, ref, form as any, {} as any);

await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
expect(fetchMock.mock.calls[0][0]).toBe(ref);
expect(fetchMock.mock.calls[0][0]).toBe("/_server/data/lazy%230");
// registration stays under the RENDERED url — what repeat submits carry
expect(actions.has(ref)).toBe(true);
});

Expand Down
26 changes: 22 additions & 4 deletions test/data/flight-consumer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,27 @@ import { createMockRouter } from "../helpers.js";
// transport's part and deliver single-flight payloads to it directly.
let consumer: FlightDataConsumer<Record<string, any>> | undefined;

// The wire shape of a scripted redirect: masked 200 with the carrier holding
// the author's status and the target resolved to an absolute url.
const carrier = (target: string, status = 302) => ({
"X-Server-Function-Redirect": `${status} ${new URL(target, window.location.href).href}`
});

vi.mock("@solidjs/web/server-functions", () => ({
decodeResponse: vi.fn(),
decodeResponsePayload: vi.fn(),
// the redirect carrier, mirrored from the runtime (wire format:
// "<status> <absolute-url>")
REDIRECT_HEADER: "X-Server-Function-Redirect",
decodeRedirectHeaderValue: (value: string | null | undefined) => {
if (typeof value !== "string") return undefined;
const at = value.indexOf(" ");
if (at < 0) return undefined;
const status = Number(value.slice(0, at));
const url = value.slice(at + 1);
if (!Number.isInteger(status) || !url) return undefined;
return { status, url };
},
// consumed by data/query.ts, which shares this module graph
isServerFunction: () => false,
getServerFunctionMetadata: () => undefined,
Expand Down Expand Up @@ -59,9 +77,9 @@ describe("setupFlightDataConsumer", () => {
setupFlightDataConsumer(router);
await consumer!(
{ "notes[]": ["destination data"] },
{ response: new Response(null, { headers: { Location: "/notes" } }) }
{ response: new Response(null, { headers: carrier("/notes") }) }
);
expect(navigate).toHaveBeenCalledWith("/notes");
expect(navigate).toHaveBeenCalledWith("/notes", { replace: true });
expect(query.get("notes[]")).toEqual(["destination data"]);
});

Expand Down Expand Up @@ -110,13 +128,13 @@ describe("setupFlightDataConsumer", () => {
const save = async () => {
await consumer!(
{ "layout[]": "fresh-layout" },
{ response: new Response(null, { headers: { Location: "/dash/b" } }) }
{ response: new Response(null, { headers: carrier("/dash/b") }) }
);
return "saved";
};
await action(save, "keyless-save").call({ r: router });

expect(navigate).toHaveBeenCalledWith("/dash/b");
expect(navigate).toHaveBeenCalledWith("/dash/b", { replace: true });
expect(await layout()).toBe("fresh-layout");
expect(fetchLayout).toHaveBeenCalledTimes(1);
});
Expand Down
24 changes: 21 additions & 3 deletions test/data/flight-redirect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ let consumer: FlightDataConsumer<Record<string, any>> | undefined;
vi.mock("@solidjs/web/server-functions", () => ({
decodeResponse: vi.fn(),
decodeResponsePayload: vi.fn(),
// the redirect carrier, mirrored from the runtime (wire format:
// "<status> <absolute-url>")
REDIRECT_HEADER: "X-Server-Function-Redirect",
decodeRedirectHeaderValue: (value: string | null | undefined) => {
if (typeof value !== "string") return undefined;
const at = value.indexOf(" ");
if (at < 0) return undefined;
const status = Number(value.slice(0, at));
const url = value.slice(at + 1);
if (!Number.isInteger(status) || !url) return undefined;
return { status, url };
},
// consumed by data/query.ts, which shares this module graph
isServerFunction: () => false,
getServerFunctionMetadata: () => undefined,
Expand All @@ -21,6 +33,12 @@ vi.mock("@solidjs/web/server-functions", () => ({
}
}));

// The wire shape of a scripted redirect: masked 200 with the carrier holding
// the author's status and the target resolved to an absolute url.
const carrier = (target: string, status = 302) => ({
"X-Server-Function-Redirect": `${status} ${new URL(target, window.location.href).href}`
});

// Spy on the sweep: these tests pin the ordering the action layer applies to
// a flight response — invalidate, seed, navigate, then sweep synchronously —
// not the query cache mechanics (covered by query.spec.ts and
Expand Down Expand Up @@ -70,7 +88,7 @@ describe("redirecting flight responses", () => {
});
await consumer!(
{ "note[0]": { title: "fresh" } },
{ response: new Response(null, { headers: { Location: "/notes/0" } }) }
{ response: new Response(null, { headers: carrier("/notes/0") }) }
);
expect(sweptDuringApply).toBe(true);
expect(sweepSpy).toHaveBeenCalledTimes(1);
Expand All @@ -82,7 +100,7 @@ describe("redirecting flight responses", () => {
{ "notes[]": ["fresh"] },
{
response: new Response(null, {
headers: { Location: "/notes", "X-Revalidate": "notes" }
headers: { ...carrier("/notes"), "X-Revalidate": "notes" }
})
}
);
Expand All @@ -109,7 +127,7 @@ describe("redirecting flight responses", () => {
await consumer!(
{},
{
response: new Response(null, { headers: { Location: "https://elsewhere.example/" } })
response: new Response(null, { headers: carrier("https://elsewhere.example/") })
}
);
expect(navigate).not.toHaveBeenCalled();
Expand Down
3 changes: 2 additions & 1 deletion test/data/query.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ describe("query", () => {
expect(result).toBe("GET result");
expect(bodyCalled).toBe(false);
expect(seen.method).toBe("GET");
expect(seen.url).toContain("/_server/auto-get-0");
// scripted reads go to the data address (solidjs/solid#3094)
expect(seen.url).toContain("/_server/data/auto-get-0");
// the declaration lives on the wrapped reference; the original's
// metadata is untouched (copy-on-declare)
expect(getServerFunctionMetadata(serverFn)).toEqual({});
Expand Down
Loading