fix: data grid changes

This commit is contained in:
nams1570 2026-07-14 10:35:20 -07:00
parent 54f19d72b5
commit be619368fc
2 changed files with 133 additions and 18 deletions

View File

@ -3,7 +3,7 @@
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useDataSource } from "./use-data-source";
import type { DataGridColumnDef, DataGridDataSource } from "./types";
import type { DataGridColumnDef, DataGridDataPaginationMode, DataGridDataSource } from "./types";
type Row = {
id: string,
@ -19,7 +19,13 @@ const columns: DataGridColumnDef<Row>[] = [
},
];
function DataSourceHarness({ dataSource }: { dataSource: DataGridDataSource<Row> }) {
function DataSourceHarness({
dataSource,
paginationMode = "infinite",
}: {
dataSource: DataGridDataSource<Row>,
paginationMode?: DataGridDataPaginationMode,
}) {
const gridData = useDataSource({
dataSource,
columns,
@ -27,13 +33,14 @@ function DataSourceHarness({ dataSource }: { dataSource: DataGridDataSource<Row>
sorting: [],
quickSearch: "",
pagination: { pageIndex: 0, pageSize: 25 },
paginationMode: "infinite",
paginationMode,
});
return (
<>
<button onClick={gridData.loadMore}>Load more</button>
<span data-testid="row-count">{gridData.rows.length}</span>
<span data-testid="row-name">{gridData.rows[0]?.name ?? ""}</span>
<span data-testid="error">{gridData.error?.message ?? ""}</span>
</>
);
@ -133,6 +140,49 @@ describe("useDataSource infinite pagination", () => {
expect(callsA).toEqual([{ cursor: undefined }, { cursor: "cursor-1" }]);
});
it("does not treat an aborted zero-yield reset as a completed fetch", async () => {
const dataSourceA: DataGridDataSource<Row> = async function* () {
yield {
rows: [{ id: "row-a", name: "A" }],
nextCursor: null,
hasMore: false,
};
};
let bCalls = 0;
let resolveFirstB: (value: void) => void = () => {};
const dataSourceB: DataGridDataSource<Row> = async function* () {
bCalls++;
if (bCalls === 1) {
// Aborted before any yield: completing this must not mark B as a
// successful skip key, or a later render with B would keep A's rows.
await new Promise<void>((resolve) => {
resolveFirstB = resolve;
});
return;
}
yield {
rows: [{ id: "row-b", name: "B" }],
nextCursor: null,
hasMore: false,
};
};
const { getByTestId, rerender } = render(
<DataSourceHarness dataSource={dataSourceA} />,
);
await waitFor(() => expect(getByTestId("row-name").textContent).toBe("A"));
rerender(<DataSourceHarness dataSource={dataSourceB} />);
rerender(<DataSourceHarness dataSource={dataSourceA} />);
await act(async () => {
resolveFirstB();
});
rerender(<DataSourceHarness dataSource={dataSourceB} />);
await waitFor(() => expect(getByTestId("row-name").textContent).toBe("B"));
expect(bCalls).toBe(2);
});
it("does not replay a deferred loadMore after the in-flight fetch errors", async () => {
const calls: Array<{ cursor: unknown }> = [];
let rejectInitial: (err: Error) => void = () => {};
@ -162,6 +212,53 @@ describe("useDataSource infinite pagination", () => {
expect(calls).toHaveLength(1);
});
it("discards a deferred loadMore when pagination mode leaves infinite", async () => {
const calls: Array<{ cursor: unknown }> = [];
let resolveInitial: (value: void) => void = () => {};
const initialFetch = new Promise<void>((resolve) => {
resolveInitial = resolve;
});
const dataSource: DataGridDataSource<Row> = async function* (params) {
calls.push({ cursor: params.cursor });
if (calls.length === 1) {
await initialFetch;
yield {
rows: [{ id: "row-1", name: "Row 1" }],
nextCursor: "cursor-1",
hasMore: true,
};
return;
}
yield {
rows: [{ id: "row-2", name: "Row 2" }],
nextCursor: null,
hasMore: false,
};
};
const { getByRole, rerender } = render(
<DataSourceHarness dataSource={dataSource} paginationMode="infinite" />,
);
await waitFor(() => expect(calls).toHaveLength(1));
fireEvent.click(getByRole("button", { name: "Load more" }));
expect(calls).toHaveLength(1);
rerender(<DataSourceHarness dataSource={dataSource} paginationMode="server" />);
await act(async () => {
resolveInitial();
});
// Mode left infinite while the deferred loadMore was queued — do not
// append against server pagination after the fetch settles.
await waitFor(() => expect(calls).toHaveLength(1));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 20));
});
expect(calls).toHaveLength(1);
});
it("uses the completed page cursor for the next loadMore fetch", async () => {
const calls: Array<{ cursor: unknown }> = [];
const dataSource: DataGridDataSource<Row> = async function* (params) {

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import type {
DataGridColumnDef,
DataGridDataSource,
@ -131,12 +132,13 @@ function useAsyncDataSource<TRow>(opts: {
// fetch settles.
const pendingLoadMoreRef = useRef(false);
const hasMoreRef = useRef(true);
const paginationModeRef = useRef(paginationMode);
paginationModeRef.current = paginationMode;
const pageIndexRef = useRef(0);
const hasDataRef = useRef(false);
const hasMountedServerPaginationRef = useRef(false);
// Effects can be replayed when a descendant suspends. Only a completed
// fetch is safe to reuse; an aborted fetch may have reset the cursor without
// ever replacing the visible rows.
// Effects can be replayed when a descendant suspends. Only a fetch that
// actually delivered results is safe to reuse as a skip key.
const lastCompletedNonAppendFetchKeyRef = useRef<{
dataSource: DataGridDataSource<TRow>,
sortingKey: string,
@ -203,6 +205,11 @@ function useAsyncDataSource<TRow>(opts: {
let cursor = append ? cursorRef.current : undefined;
let pageIndex = append ? pageIndexRef.current : 0;
let failed = false;
// The abort guard lives inside the for-await body, so a generator that
// completes with zero yields never hits it and would otherwise fall
// through to the post-loop completion-key write. Only mark a reset
// complete after it has actually delivered (and committed) a result.
let committedResult = false;
try {
const params: DataGridFetchParams = {
@ -246,10 +253,11 @@ function useAsyncDataSource<TRow>(opts: {
}
hasDataRef.current = true;
committedResult = true;
pageIndex++;
pageIndexRef.current = pageIndex;
}
if (!append) {
if (!append && committedResult && !controller.signal.aborted) {
lastCompletedNonAppendFetchKeyRef.current = fetchKey;
}
} catch (err) {
@ -272,19 +280,21 @@ function useAsyncDataSource<TRow>(opts: {
setIsRefetching(false);
setIsLoadingMore(false);
// Replay a loadMore that was requested (and deferred) while this
// fetch was in flight — but only if this fetch succeeded. Skip on
// failure (the append would run against inconsistent state and its
// setError(null) would hide this fetch's error) and on
// unmount-cleanup aborts — `inFlightFetchRef.current === controller`
// with an aborted signal only happens then.
// fetch was in flight — but only if this fetch succeeded while we
// are still in infinite mode. Skip on failure (the append would run
// against inconsistent state and its setError(null) would hide this
// fetch's error), when pagination mode changed away from infinite,
// and on unmount-cleanup aborts — `inFlightFetchRef.current ===
// controller` with an aborted signal only happens then.
const shouldChainLoadMore =
pendingLoadMoreRef.current
&& !failed
&& !controller.signal.aborted
&& hasMoreRef.current;
&& hasMoreRef.current
&& paginationModeRef.current === "infinite";
pendingLoadMoreRef.current = false;
if (shouldChainLoadMore) {
fetchPage(true).catch(() => {});
runAsynchronously(fetchPage(true));
}
}
}
@ -292,6 +302,14 @@ function useAsyncDataSource<TRow>(opts: {
[],
);
useEffect(() => {
// Deferred loadMore is only meaningful for infinite scroll; drop it when
// the consumer leaves that mode so a later settle cannot append wrongly.
if (paginationMode !== "infinite") {
pendingLoadMoreRef.current = false;
}
}, [paginationMode]);
useEffect(() => {
const lastCompletedKey = lastCompletedNonAppendFetchKeyRef.current;
const isSameCompletedFetch = hasDataRef.current
@ -300,7 +318,7 @@ function useAsyncDataSource<TRow>(opts: {
&& lastCompletedKey.quickSearchKey === quickSearchKey
&& lastCompletedKey.pageSize === pagination.pageSize;
if (!isSameCompletedFetch) {
fetchPage(false).catch(() => {});
runAsynchronously(fetchPage(false));
}
// Always return the abort cleanup (even when the fetch is skipped) so an
// in-flight append is cancelled on unmount.
@ -319,7 +337,7 @@ function useAsyncDataSource<TRow>(opts: {
hasMountedServerPaginationRef.current = true;
return;
}
fetchPage(false).catch(() => {});
runAsynchronously(fetchPage(false));
}, [fetchPage, paginationMode, pagination.pageIndex]);
const loadMore = useCallback(() => {
@ -333,11 +351,11 @@ function useAsyncDataSource<TRow>(opts: {
pendingLoadMoreRef.current = true;
return;
}
fetchPage(true).catch(() => {});
runAsynchronously(fetchPage(true));
}, [hasMore, paginationMode, fetchPage]);
const reload = useCallback(() => {
fetchPage(false).catch(() => {});
runAsynchronously(fetchPage(false));
}, [fetchPage]);
return {