diff --git a/.changeset/prepare-request-sync-send.md b/.changeset/prepare-request-sync-send.md new file mode 100644 index 000000000..e55854ba6 --- /dev/null +++ b/.changeset/prepare-request-sync-send.md @@ -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. diff --git a/packages/web/server-functions/src/client.ts b/packages/web/server-functions/src/client.ts index dbfda6c8e..1c16257c9 100644 --- a/packages/web/server-functions/src/client.ts +++ b/packages/web/server-functions/src/client.ts @@ -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; /** @@ -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 diff --git a/packages/web/test/hydration/prepare-request-hydration-3959.spec.tsx b/packages/web/test/hydration/prepare-request-hydration-3959.spec.tsx new file mode 100644 index 000000000..680f634d5 --- /dev/null +++ b/packages/web/test/hydration/prepare-request-hydration-3959.spec.tsx @@ -0,0 +1,56 @@ +/** + * @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, sharedConfig } from "solid-js"; +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 = { 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([]); +}); diff --git a/packages/web/test/server/server-functions-extensions.spec.tsx b/packages/web/test/server/server-functions-extensions.spec.tsx index 2ee22e543..e2bc4528e 100644 --- a/packages/web/test/server/server-functions-extensions.spec.tsx +++ b/packages/web/test/server/server-functions-extensions.spec.tsx @@ -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[] = []; + 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))