Compare commits

...
Author SHA1 Message Date
Joe Li 02ac869ad4 fix(frontend): ignore stale list responses 2026-08-18 11:13:15 -07:00
2 changed files with 177 additions and 10 deletions
@@ -43,6 +43,21 @@ function findEndpoint(spy: jest.SpyInstance, substring: string): string {
return (match[0] as Record<string, string>).endpoint;
}
function deferredJsonResponse() {
let resolveResponse: ((value: JsonResponse) => void) | undefined;
let rejectResponse: ((reason?: unknown) => void) | undefined;
const promise = new Promise<JsonResponse>((resolve, reject) => {
resolveResponse = resolve;
rejectResponse = reject;
});
if (!resolveResponse || !rejectResponse) {
throw new Error('Deferred response handlers were not initialized');
}
return { promise, resolve: resolveResponse, reject: rejectResponse };
}
beforeEach(() => {
jest.restoreAllMocks();
});
@@ -282,6 +297,147 @@ test('useListViewResource: fetchData sets loading to true then false', async ()
});
});
test('useListViewResource: ignores an older response that resolves last', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
const toISOString = jest
.spyOn(Date.prototype, 'toISOString')
.mockReturnValueOnce('newer-response-time')
.mockReturnValueOnce('older-response-time');
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn(), false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
newer.resolve({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.resourceCount).toBe(0);
expect(result.current.state.lastFetched).toBe('newer-response-time');
await act(async () => {
older.resolve({
json: { result: [{ id: 1 }, { id: 2 }], count: 2 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.resourceCount).toBe(0);
expect(result.current.state.lastFetched).toBe('newer-response-time');
expect(toISOString).toHaveBeenCalledTimes(1);
});
test('useListViewResource: stale completion keeps the latest request loading', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn(), false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
older.resolve({
json: { result: [{ id: 1 }], count: 1 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.loading).toBe(true);
await act(async () => {
newer.resolve({
json: { result: [{ id: 2 }], count: 1 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([{ id: 2 }]);
expect(result.current.state.loading).toBe(false);
});
test('useListViewResource: only the latest request reports an error', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
const handleErrorMsg = jest.fn();
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', handleErrorMsg, false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
older.reject('older request failed');
});
expect(handleErrorMsg).not.toHaveBeenCalled();
expect(result.current.state.loading).toBe(true);
await act(async () => {
newer.reject('newer request failed');
});
expect(handleErrorMsg).toHaveBeenCalledTimes(1);
expect(result.current.state.loading).toBe(false);
});
test('useListViewResource: refreshData re-fetches with last config', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
+21 -10
View File
@@ -148,6 +148,7 @@ export function useListViewResource<D extends object = any>(
);
const lastFetchDataConfigRef = useRef<FetchDataConfig | null>(null);
const latestRequestIdRef = useRef(0);
const fetchData = useCallback(
({
@@ -156,6 +157,9 @@ export function useListViewResource<D extends object = any>(
sortBy,
filters: filterValues,
}: FetchDataConfig) => {
const requestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = requestId;
const isLatest = () => latestRequestIdRef.current === requestId;
const config: FetchDataConfig = {
filters: filterValues,
pageIndex,
@@ -196,24 +200,31 @@ export function useListViewResource<D extends object = any>(
})
.then(
({ json = {} }) => {
if (!isLatest()) {
return;
}
updateState({
collection: json.result,
count: json.count,
lastFetched: new Date().toISOString(),
});
},
createErrorHandler(errMsg =>
handleErrorMsg(
t(
'An error occurred while fetching %ss: %s',
resourceLabel,
errMsg,
),
),
),
createErrorHandler(errMsg => {
if (isLatest()) {
handleErrorMsg(
t(
'An error occurred while fetching %ss: %s',
resourceLabel,
errMsg,
),
);
}
}),
)
.finally(() => {
updateState({ loading: false });
if (isLatest()) {
updateState({ loading: false });
}
});
},
[