From fc45d08b26ac8e433fc7899a12be392cc52735ba Mon Sep 17 00:00:00 2001 From: Taesu <166604494+bytaesu@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:53:49 +0000 Subject: [PATCH] fix(client): prevent stale query overwrites (#11376) --- .changeset/steady-query-results.md | 5 + packages/better-auth/src/client/query.test.ts | 93 +++++++++++++++++++ packages/better-auth/src/client/query.ts | 73 ++++++++------- 3 files changed, 139 insertions(+), 32 deletions(-) create mode 100644 .changeset/steady-query-results.md diff --git a/.changeset/steady-query-results.md b/.changeset/steady-query-results.md new file mode 100644 index 0000000000..29cfe8f8ba --- /dev/null +++ b/.changeset/steady-query-results.md @@ -0,0 +1,5 @@ +--- +"better-auth": patch +--- + +Prevent older auth-query responses from replacing newer results when requests overlap. diff --git a/packages/better-auth/src/client/query.test.ts b/packages/better-auth/src/client/query.test.ts index e4443f0ca1..ea71ed316f 100644 --- a/packages/better-auth/src/client/query.test.ts +++ b/packages/better-auth/src/client/query.test.ts @@ -108,6 +108,99 @@ describe("useAuthQuery - error handling", () => { expect(session().data).toBeNull(); }); + it.each([ + { + earlier: "unauthorized", + initialOutcome: () => + Response.json({ message: "Unauthorized" }, { status: 401 }), + refreshedResponse: () => Response.json({ id: "current" }), + expectedData: { id: "current" }, + }, + { + earlier: "successful", + initialOutcome: () => Response.json({ id: "previous" }), + refreshedResponse: () => + Response.json({ message: "Unauthorized" }, { status: 401 }), + expectedData: null, + }, + { + earlier: "network failure", + initialOutcome: () => new TypeError("Failed to fetch"), + refreshedResponse: () => Response.json({ id: "current" }), + expectedData: { id: "current" }, + }, + ])("keeps the latest result when an earlier $earlier request finishes last", async ({ + initialOutcome, + refreshedResponse, + expectedData, + }) => { + const initialRequest = Promise.withResolvers(); + const fetchImpl = vi + .fn(async () => refreshedResponse()) + .mockImplementationOnce(() => initialRequest.promise); + const $fetch = createFetch({ + baseURL: "http://localhost:3000", + customFetchImpl: fetchImpl, + }); + + const $signal = atom(false); + const query = useAuthQuery<{ id: string }>($signal, "/test", $fetch, { + method: "GET", + }); + const unsubscribe = query.listen(() => {}); + await vi.advanceTimersByTimeAsync(0); + expect(fetchImpl).toHaveBeenCalledOnce(); + + $signal.set(true); + await vi.advanceTimersByTimeAsync(0); + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(query.get().data).toEqual(expectedData); + const latestState = query.get(); + + const outcome = initialOutcome(); + if (outcome instanceof Error) { + initialRequest.reject(outcome); + } else { + initialRequest.resolve(outcome); + } + await vi.advanceTimersByTimeAsync(0); + + expect(query.get()).toEqual(latestState); + unsubscribe(); + }); + + it("runs fetch callbacks for superseded requests", async () => { + const initialRequest = Promise.withResolvers(); + const fetchImpl = vi + .fn(async () => Response.json({ id: "current" })) + .mockImplementationOnce(() => initialRequest.promise); + const $fetch = createFetch({ + baseURL: "http://localhost:3000", + customFetchImpl: fetchImpl, + }); + const onSuccess = vi.fn(); + const onError = vi.fn(); + const query = useAuthQuery(atom(false), "/test", $fetch, { + method: "GET", + onSuccess, + onError, + }); + + const unsubscribe = query.listen(() => {}); + await vi.advanceTimersByTimeAsync(0); + expect(fetchImpl).toHaveBeenCalledOnce(); + + await query.get().refetch(); + initialRequest.resolve( + Response.json({ message: "Unauthorized" }, { status: 401 }), + ); + await vi.advanceTimersByTimeAsync(0); + + expect(onSuccess).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledOnce(); + unsubscribe(); + }); + it("should normalize null session responses to null data", async () => { const client = createAuthClient({ plugins: [testClientPlugin()], diff --git a/packages/better-auth/src/client/query.ts b/packages/better-auth/src/client/query.ts index c6a38c3dae..81ecdf06ad 100644 --- a/packages/better-auth/src/client/query.ts +++ b/packages/better-auth/src/client/query.ts @@ -59,10 +59,12 @@ export const useAuthQuery = ( refetch: (queryParams) => fn(queryParams), }); onMount(value, () => withEquality(value, isAuthQueryStateEqual)); + let latestRequestId = 0; const fn = async ( queryParams?: { query?: SessionQueryParams } | undefined, ) => { + const requestId = ++latestRequestId; return new Promise((resolve) => { const opts = typeof options === "function" @@ -80,20 +82,22 @@ export const useAuthQuery = ( ...queryParams?.query, }, async onSuccess(context) { - const current = value.get(); - const stableData = - current.data != null && - context.data != null && - isJsonEqual(current.data, context.data) - ? current.data - : context.data; - value.set({ - data: stableData, - error: null, - isPending: false, - isRefetching: false, - refetch: value.value.refetch, - }); + if (requestId === latestRequestId) { + const current = value.get(); + const stableData = + current.data != null && + context.data != null && + isJsonEqual(current.data, context.data) + ? current.data + : context.data; + value.set({ + data: stableData, + error: null, + isPending: false, + isRefetching: false, + refetch: value.value.refetch, + }); + } await opts?.onSuccess?.(context); }, async onError(context) { @@ -104,31 +108,36 @@ export const useAuthQuery = ( : request.retry?.attempts; const retryAttempt = request.retryAttempt || 0; if (retryAttempts && retryAttempt < retryAttempts) return; - const isUnauthorized = context.error.status === 401; - value.set({ - error: context.error, - data: isUnauthorized - ? null // clear session on HTTP 401 - : value.get().data, // preserve stale data on other errors - isPending: false, - isRefetching: false, - refetch: value.value.refetch, - }); + if (requestId === latestRequestId) { + const isUnauthorized = context.error.status === 401; + value.set({ + error: context.error, + data: isUnauthorized + ? null // clear session on HTTP 401 + : value.get().data, // preserve stale data on other errors + isPending: false, + isRefetching: false, + refetch: value.value.refetch, + }); + } await opts?.onError?.(context); }, async onRequest(context) { - const currentValue = value.get(); - value.set({ - isPending: currentValue.data === null, - data: currentValue.data, - error: null, - isRefetching: true, - refetch: value.value.refetch, - }); + if (requestId === latestRequestId) { + const currentValue = value.get(); + value.set({ + isPending: currentValue.data === null, + data: currentValue.data, + error: null, + isRefetching: true, + refetch: value.value.refetch, + }); + } await opts?.onRequest?.(context); }, }) .catch((error) => { + if (requestId !== latestRequestId) return; value.set({ error, data: value.get().data,