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/prepare-request-sync-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

A synchronous `prepareRequest` hook no longer defers the server-function request by a microtask, so reads adopted during hydration stay inside the hydration trace instead of refetching from the browser (#3959). An async hook is still awaited.
8 changes: 7 additions & 1 deletion packages/web/server-functions/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ export interface ServerFunctionsClientConfig {
* }
* });
* ```
*
* Return synchronously when you can: hydration adopts a server-rendered
* read only if its request is sent in the same tick, so an async hook
* makes every hydrated read fetch again from the browser.
*/
prepareRequest?: PrepareRequestHook;
/**
Expand Down Expand Up @@ -608,7 +612,9 @@ async function createRequest(base, id, options, meta) {
// RequestInit (transport headers included), so session policy can adjust
// anything the transport is about to send.
if (config.prepareRequest) {
const prepared = await config.prepareRequest(init, { id, meta });
let prepared = config.prepareRequest(init, { id, meta });
// A sync hook must send in this tick: hydration's fetch mock is only in place synchronously.
if (prepared && typeof prepared.then === "function") prepared = await prepared;
// The hook's return is validated, not adopted wholesale (#3174): the
// natural mistake — returning a fresh `{ headers }` instead of
// spreading — used to silently drop the argument payload, the abort
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* @vitest-environment jsdom
*
* #3959: a synchronous `prepareRequest` hook must send inside hydration's
* synchronous trace, where the mocked fetch absorbs the adopted read. Before
* the fix the hook was always awaited, so the read reached the network.
*/
import { afterEach, expect, test, vi } from "vitest";
import { createMemo, createRoot, enableHydration, flush } from "solid-js";
import { sharedConfig } from "solid-js/internal";
import {
configureServerFunctionsClient,
createServerReference
} from "../../server-functions/src/client.js";

enableHydration();

const { has, load, gather } = sharedConfig as any;

afterEach(() => {
sharedConfig.hydrating = false;
Object.assign(sharedConfig, { has, load, gather });
configureServerFunctionsClient({ prepareRequest: null as any });
vi.unstubAllGlobals();
});

test.each([
["no prepareRequest", undefined],
["a synchronous prepareRequest", (init: RequestInit) => init]
])("an adopted server-function read does not refetch with %s", async (_, hook) => {
const data: Record<string, unknown> = { t0: "server" };
sharedConfig.hydrating = true;
(sharedConfig as any).has = (id: string) => id in data;
(sharedConfig as any).load = (id: string) => data[id];
(sharedConfig as any).gather = () => {};
const urls: string[] = [];
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
urls.push(String(input));
return new Response("null");
});
if (hook) configureServerFunctionsClient({ prepareRequest: hook });

const read = createServerReference("prepare-request-3959");
let value!: () => unknown;
createRoot(
() => {
value = createMemo(() => read());
},
{ id: "t" }
);
flush();
sharedConfig.hydrating = false;
await new Promise(resolve => setTimeout(resolve, 20));

expect(value()).toBe("server");
expect(urls).toEqual([]);
});
33 changes: 33 additions & 0 deletions packages/web/test/server/server-functions-extensions.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,39 @@ describe("server-function extension surface (built bundles)", () => {
}
});

it("sends in the calling tick when prepareRequest returns synchronously", async () => {
registerServerFunction("ext-fetch-8", async (n?: number) => n ?? 0);
const restore = connectTransport();
const send = globalThis.fetch;
let sends = 0;
// hydration swaps the global fetch only for one synchronous compute (#3959)
globalThis.fetch = ((address: any, init?: RequestInit) => {
sends++;
return send(address, init);
}) as typeof fetch;
configureServerFunctionsClient({ prepareRequest: init => init });
const calls: Promise<unknown>[] = [];
try {
const plain = createServerReference("ext-fetch-8")();
const withArg = createServerReference("ext-fetch-8")(5);
calls.push(plain, withArg);
expect(sends).toBe(2);
expect(await plain).toBe(0);
expect(await withArg).toBe(5);

configureServerFunctionsClient({ prepareRequest: async init => init });
const deferred = createServerReference("ext-fetch-8")(7);
calls.push(deferred);
expect(sends).toBe(2);
expect(await deferred).toBe(7);
expect(sends).toBe(3);
} finally {
await Promise.allSettled(calls);
configureServerFunctionsClient({ prepareRequest: null as any });
restore();
}
});

it("sends a GET-declared read through the configured fetch too", async () => {
serverGET(
createServerSideReference(registerServerReference("ext-fetch-5", async (n: number) => n * 2))
Expand Down
Loading