fix(client): prevent stale query overwrites (#11376)

This commit is contained in:
Taesu
2026-09-24 04:53:49 +00:00
committed by GitHub
parent 2b13e011b4
commit fc45d08b26
3 changed files with 139 additions and 32 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Prevent older auth-query responses from replacing newer results when requests overlap.
@@ -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<Response>();
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<Response>();
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()],
+41 -32
View File
@@ -59,10 +59,12 @@ export const useAuthQuery = <T>(
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<void>((resolve) => {
const opts =
typeof options === "function"
@@ -80,20 +82,22 @@ export const useAuthQuery = <T>(
...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 = <T>(
: 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,