diff --git a/src/data/action.ts b/src/data/action.ts index 586daa7b..0ca0949b 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -3,6 +3,7 @@ import { isResponseEnvelope, isServer, type JSX } from "@solidjs/web"; import { createServerReference, decodeResponse, + isServerFunction, subscribeFlightData, type SingleFlightPayload } from "@solidjs/web/server-functions"; @@ -148,7 +149,7 @@ function createServerFormAction( (form: FormData | URLSearchParams) => stub(form) as Promise, { url } ); - return actionImpl(caller); + return actionImpl(caller, {}, true); } /** @@ -219,7 +220,8 @@ export function useAction, U, V>(action: Action) { function actionImpl, U = void>( fn: (...args: T) => Promise, - options: string | { name?: string } = {} + options: string | { name?: string } = {}, + serverFunction = isServerFunction(fn) ): Action { async function invoke( this: { r: RouterContext; f?: HTMLFormElement }, @@ -262,7 +264,12 @@ function actionImpl, U = void>( : undefined }) ); - response = await handleResponse(settled.value, settled.error, router.navigatorFactory()); + response = await handleResponse( + settled.value, + settled.error, + router.navigatorFactory(), + serverFunction && router.singleFlight + ); } finally { form && setFormBusy(form, -1); } @@ -430,7 +437,12 @@ async function applyResponseMetadata( await revalidate(keys, false); } -async function handleResponse(response: unknown, error: boolean | undefined, navigate: Navigator) { +async function handleResponse( + response: unknown, + error: boolean | undefined, + navigate: Navigator, + metadataHandled: boolean +) { let data: any; let flightData: Record | undefined; let metadata: Response | undefined; @@ -455,6 +467,10 @@ async function handleResponse(response: unknown, error: boolean | undefined, nav } } else if (error) return { error: response }; else data = response; - await applyResponseMetadata(metadata, navigate, flightData); + // The transport consumer applies metadata before returning a server + // function's unwrapped value. Do not treat that value as a second plain + // action response and invalidate the freshly seeded query cache again. + if (!metadataHandled || metadata || flightData) + await applyResponseMetadata(metadata, navigate, flightData); return data != null ? { data } : undefined; } diff --git a/test/data/flight-consumer.spec.ts b/test/data/flight-consumer.spec.ts index 77fc6645..40f3393e 100644 --- a/test/data/flight-consumer.spec.ts +++ b/test/data/flight-consumer.spec.ts @@ -9,6 +9,8 @@ let consumer: FlightDataConsumer> | undefined; vi.mock("@solidjs/web/server-functions", () => ({ decodeResponse: vi.fn(), + isServerFunction: (fn: unknown) => + typeof fn === "function" && !!(fn as any)[Symbol.for("solid.ServerFunctionMetadata")], subscribeFlightData: (c: FlightDataConsumer>) => { consumer = c; return () => { @@ -70,6 +72,52 @@ describe("setupFlightDataConsumer", () => { ); expect(query.get("notes[]")).toEqual(["fresh"]); }); + + test("does not revalidate flight data again after a server action settles", async () => { + const fetchNotes = vi.fn(async () => ["stale"]); + const notes = query(fetchNotes, "notes"); + await notes(); + + (router as any).singleFlight = true; + setupFlightDataConsumer(router); + const save = async () => { + await consumer!( + { "notes[]": ["fresh"] }, + { response: new Response(null, { headers: { "X-Revalidate": "notes" } }) } + ); + return "saved"; + }; + (save as any)[Symbol.for("solid.ServerFunctionMetadata")] = {}; + + await action(save, "save-notes").call({ r: router }); + + expect(await notes()).toEqual(["fresh"]); + expect(fetchNotes).toHaveBeenCalledTimes(1); + }); + + test("continues to revalidate plain client action results", async () => { + const fetchNotes = vi.fn(async () => ["notes"]); + const notes = query(fetchNotes, "notes"); + await notes(); + + await action(async () => "saved", "save-notes").call({ r: router }); + await notes(); + + expect(fetchNotes).toHaveBeenCalledTimes(2); + }); + + test("continues to revalidate server actions when single flight is disabled", async () => { + const fetchNotes = vi.fn(async () => ["notes"]); + const notes = query(fetchNotes, "notes"); + await notes(); + const save = async () => "saved"; + (save as any)[Symbol.for("solid.ServerFunctionMetadata")] = {}; + + await action(save, "save-notes").call({ r: router }); + await notes(); + + expect(fetchNotes).toHaveBeenCalledTimes(2); + }); }); // The Router no longer imports setupFlightDataConsumer; it registers itself