mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
fix(client): prevent stale query overwrites (#11376)
This commit is contained in:
@@ -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()],
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user