@marimo-team/islands 0.25.3-dev162 → 0.25.3-dev168

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { a as e } from "./rolldown-runtime-Cb76FRpK.js";
2
2
  import { t } from "./react-sHtZdjeP.js";
3
- import { a as n } from "./glide-data-editor-Dn1Ekucb.js";
3
+ import { a as n } from "./glide-data-editor-BOcECwyE.js";
4
4
  //#region ../node_modules/.pnpm/@glideapps+glide-data-grid@6.0.4-alpha24_lodash@4.17.21_marked@15.0.12_react-dom@19.3.0_27cffe7f1bc30287d59ac27db3240c5b/node_modules/@glideapps/glide-data-grid/dist/esm/internal/data-grid-overlay-editor/private/number-overlay-editor-style.js
5
5
  var r = /* @__PURE__ */ e(t()), i = /*#__PURE__*/ n("div")({
6
6
  name: "NumberOverlayEditorStyle",
@@ -4,14 +4,14 @@ import { t as o } from "./react-sHtZdjeP.js";
4
4
  import { t as s } from "./compiler-runtime-CldRdSsv.js";
5
5
  import { m as c, t as l } from "./tooltip-CJ4Rqasp.js";
6
6
  import { k as u } from "./error-entries-BpUJSLUZ.js";
7
- import { $ as d, Q as f, Z as p, an as m, i as h, l as g, ln as _, n as v, on as y, r as b, t as x, u as S } from "./common-BmlT-bPP.js";
7
+ import { $ as d, Q as f, Z as p, an as m, i as h, l as g, ln as _, n as v, on as y, r as b, t as x, u as S } from "./common-Ceqqp1YL.js";
8
8
  import { M as C } from "./select-CkF_GBxO.js";
9
9
  import { J as ee } from "./dropdown-menu-BWnnAstC.js";
10
10
  import { _ as w } from "./toDate-rySyYxqk.js";
11
11
  import { t as T } from "./jsx-runtime-3BD1kT0s.js";
12
12
  import { q as E } from "./use-restore-focus-CmGwRNId.js";
13
13
  import { A as D, C as O, b as k } from "./useTheme-TLkqJsvo.js";
14
- import { a as A, n as te, t as ne } from "./code-visibility-DEIiTBMH.js";
14
+ import { a as A, n as te, t as ne } from "./code-visibility-BfRdYY7f.js";
15
15
  /* empty css */
16
16
  var j = c("sticky-note", [["path", {
17
17
  d: "M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.25.3-dev162",
3
+ "version": "0.25.3-dev168",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -17,6 +17,7 @@ import type { DataTableSelection } from "./types";
17
17
 
18
18
  interface TableBottomBarProps<TData> {
19
19
  pagination: boolean;
20
+ hasNextPage?: boolean;
20
21
  totalColumns: number;
21
22
  selection?: DataTableSelection;
22
23
  onRowSelectionChange?: (value: RowSelectionState) => void;
@@ -30,6 +31,7 @@ interface TableBottomBarProps<TData> {
30
31
 
31
32
  export const TableBottomBar = <TData,>({
32
33
  pagination,
34
+ hasNextPage,
33
35
  totalColumns,
34
36
  selection,
35
37
  onRowSelectionChange,
@@ -147,7 +149,7 @@ export const TableBottomBar = <TData,>({
147
149
  const { totalColumns: effectiveTotalColumns, hiddenColumns } =
148
150
  getColumnCountForDisplay(table, totalColumns);
149
151
  const { rowsAndColumns, hiddenSuffix } = prettifyRowColumnCount({
150
- numRows: table.getRowCount(),
152
+ numRows: hasNextPage === undefined ? table.getRowCount() : "too_many",
151
153
  totalColumns: effectiveTotalColumns,
152
154
  hiddenColumns,
153
155
  locale,
@@ -178,6 +180,7 @@ export const TableBottomBar = <TData,>({
178
180
  <DataTablePagination
179
181
  table={table}
180
182
  tableLoading={tableLoading}
183
+ hasNextPage={hasNextPage}
181
184
  showPageSizeSelector={showPageSizeSelector}
182
185
  />
183
186
  )}
@@ -9,9 +9,11 @@ import { DataTablePagination, matchingPageRanges } from "../pagination";
9
9
  function PaginationHarness({
10
10
  totalPages = 3,
11
11
  loading = false,
12
+ hasNextPage,
12
13
  }: {
13
14
  totalPages?: number;
14
15
  loading?: boolean;
16
+ hasNextPage?: boolean;
15
17
  }) {
16
18
  const table = useReactTable({
17
19
  locale: "en-US",
@@ -23,7 +25,11 @@ function PaginationHarness({
23
25
  });
24
26
  return (
25
27
  <TooltipProvider>
26
- <DataTablePagination table={table} tableLoading={loading} />
28
+ <DataTablePagination
29
+ table={table}
30
+ tableLoading={loading}
31
+ hasNextPage={hasNextPage}
32
+ />
27
33
  </TooltipProvider>
28
34
  );
29
35
  }
@@ -140,3 +146,24 @@ test("prefix 1 with small totalPages", () => {
140
146
  test("prefix 1 with totalPages=1", () => {
141
147
  expect(matchingPageRanges("1", 1)).toEqual([[1, 1]]);
142
148
  });
149
+
150
+ test("unknown totals use lookahead for navigation without a last page or page picker", () => {
151
+ const { rerender } = render(
152
+ <PaginationHarness totalPages={-1} hasNextPage={true} />,
153
+ );
154
+ expect(screen.getByText("1")).toBeInTheDocument();
155
+ expect(
156
+ screen.queryByRole("button", { name: "Last page" }),
157
+ ).not.toBeInTheDocument();
158
+ expect(
159
+ screen.queryByRole("button", { name: /Choose page/ }),
160
+ ).not.toBeInTheDocument();
161
+ expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
162
+ fireEvent.click(screen.getByRole("button", { name: "Next page" }));
163
+ expect(screen.getByText("2")).toBeInTheDocument();
164
+ rerender(<PaginationHarness totalPages={-1} hasNextPage={false} />);
165
+ expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled();
166
+ fireEvent.click(screen.getByRole("button", { name: "Previous page" }));
167
+ expect(screen.getByText("1")).toBeInTheDocument();
168
+ expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled();
169
+ });
@@ -86,6 +86,7 @@ interface DataTableProps<TData> extends Partial<ExportActionProps> {
86
86
  setSorting?: OnChangeFn<SortingState>; // controlled sorting
87
87
  // Pagination
88
88
  totalRows: number | TooManyRows;
89
+ hasNextPage?: boolean;
89
90
  // JSON-serialized size of the currently-rendered data. Forwarded to
90
91
  // ExportActions so hosts can size-gate the Export button via downloadSizeLimitAtom.
91
92
  sizeBytes?: number | null;
@@ -143,6 +144,7 @@ const DataTableInternal = <TData,>({
143
144
  selection,
144
145
  totalColumns,
145
146
  totalRows,
147
+ hasNextPage,
146
148
  sizeBytes,
147
149
  sizeBytesIsLoading,
148
150
  manualSorting = false,
@@ -255,6 +257,7 @@ const DataTableInternal = <TData,>({
255
257
  getCoreRowModel: getCoreRowModel(),
256
258
  // pagination
257
259
  rowCount: totalRows === "too_many" ? undefined : totalRows,
260
+ pageCount: hasNextPage === undefined ? undefined : -1,
258
261
  ...(setPaginationState
259
262
  ? {
260
263
  onPaginationChange: setPaginationState,
@@ -432,6 +435,7 @@ const DataTableInternal = <TData,>({
432
435
  className="pt-1.5 pb-0.5 border-t border-border"
433
436
  totalColumns={totalColumns}
434
437
  pagination={pagination}
438
+ hasNextPage={hasNextPage}
435
439
  selection={selection}
436
440
  onRowSelectionChange={onRowSelectionChange}
437
441
  table={table}
@@ -41,21 +41,24 @@ import { PluralWord } from "@/utils/pluralize";
41
41
  interface DataTablePaginationProps<TData> {
42
42
  table: Table<TData>;
43
43
  tableLoading?: boolean;
44
+ hasNextPage?: boolean;
44
45
  showPageSizeSelector?: boolean;
45
46
  }
46
47
 
47
48
  export const DataTablePagination = <TData,>({
48
49
  table,
49
50
  tableLoading,
51
+ hasNextPage,
50
52
  showPageSizeSelector,
51
53
  }: DataTablePaginationProps<TData>) => {
52
54
  const { locale } = useLocale();
53
55
 
54
- const currentPage = Math.min(
55
- table.getState().pagination.pageIndex + 1,
56
- table.getPageCount(),
57
- );
58
56
  const totalPages = table.getPageCount();
57
+ const hasKnownTotal = totalPages !== -1;
58
+ const pageNumber = table.getState().pagination.pageIndex + 1;
59
+ const currentPage = hasKnownTotal
60
+ ? Math.min(pageNumber, totalPages)
61
+ : pageNumber;
59
62
 
60
63
  const pageSize = table.getState().pagination.pageSize;
61
64
 
@@ -142,16 +145,22 @@ export const DataTablePagination = <TData,>({
142
145
  </Tooltip>
143
146
  <div className="flex items-center justify-center text-xs font-medium gap-1">
144
147
  <span>Page</span>
145
- <PageSelector
146
- currentPage={currentPage}
147
- totalPages={totalPages}
148
- onPageChange={(page) =>
149
- handlePageChange(() => table.setPageIndex(page))
150
- }
151
- />
152
- <span className="shrink-0">
153
- of {prettyNumber(totalPages, locale)}
154
- </span>
148
+ {hasKnownTotal ? (
149
+ <>
150
+ <PageSelector
151
+ currentPage={currentPage}
152
+ totalPages={totalPages}
153
+ onPageChange={(page) =>
154
+ handlePageChange(() => table.setPageIndex(page))
155
+ }
156
+ />
157
+ <span className="shrink-0">
158
+ of {prettyNumber(totalPages, locale)}
159
+ </span>
160
+ </>
161
+ ) : (
162
+ <span>{prettyNumber(currentPage, locale)}</span>
163
+ )}
155
164
  </div>
156
165
  <Tooltip content="Next page">
157
166
  <Button
@@ -162,29 +171,31 @@ export const DataTablePagination = <TData,>({
162
171
  className="h-6 w-5 p-0"
163
172
  onClick={() => handlePageChange(() => table.nextPage())}
164
173
  onMouseDown={Events.preventFocus}
165
- disabled={!table.getCanNextPage()}
174
+ disabled={tableLoading || !(hasNextPage ?? table.getCanNextPage())}
166
175
  >
167
176
  <ChevronRight className="h-4 w-4" />
168
177
  </Button>
169
178
  </Tooltip>
170
- <Tooltip content="Last page">
171
- <Button
172
- size="xs"
173
- variant="text"
174
- data-testid="last-page-button"
175
- aria-label="Last page"
176
- className="hidden h-6 w-5 p-0 lg:flex"
177
- onClick={() =>
178
- handlePageChange(() =>
179
- table.setPageIndex(table.getPageCount() - 1),
180
- )
181
- }
182
- onMouseDown={Events.preventFocus}
183
- disabled={!table.getCanNextPage()}
184
- >
185
- <ChevronsRight className="h-4 w-4" />
186
- </Button>
187
- </Tooltip>
179
+ {hasKnownTotal && (
180
+ <Tooltip content="Last page">
181
+ <Button
182
+ size="xs"
183
+ variant="text"
184
+ data-testid="last-page-button"
185
+ aria-label="Last page"
186
+ className="hidden h-6 w-5 p-0 lg:flex"
187
+ onClick={() =>
188
+ handlePageChange(() =>
189
+ table.setPageIndex(table.getPageCount() - 1),
190
+ )
191
+ }
192
+ onMouseDown={Events.preventFocus}
193
+ disabled={!table.getCanNextPage()}
194
+ >
195
+ <ChevronsRight className="h-4 w-4" />
196
+ </Button>
197
+ </Tooltip>
198
+ )}
188
199
  </div>
189
200
  </div>
190
201
  );
@@ -231,6 +231,7 @@ type DataTableFunctions = {
231
231
  }) => Promise<{
232
232
  data: TableData<T>;
233
233
  total_rows: number | TooManyRows;
234
+ has_next_page?: boolean | null;
234
235
  cell_styles?: CellStyleState | null;
235
236
  cell_hover_texts?: Record<string, Record<string, string | null>> | null;
236
237
  raw_data?: TableData<T> | null;
@@ -343,6 +344,7 @@ export const DataTablePlugin = createPlugin<S>("marimo-table")
343
344
  z.object({
344
345
  data: z.union([z.string(), z.array(z.looseObject({}))]),
345
346
  total_rows: z.union([z.number(), z.literal(TOO_MANY_ROWS)]),
347
+ has_next_page: z.boolean().nullish(),
346
348
  cell_styles: z
347
349
  .record(z.string(), z.record(z.string(), z.looseObject({})))
348
350
  .nullable(),
@@ -507,6 +509,7 @@ interface DataTableSearchProps {
507
509
  filters?: ColumnFiltersState;
508
510
  setFilters?: SetFilters;
509
511
  hasStableRowId: boolean;
512
+ hasNextPage?: boolean;
510
513
  }
511
514
 
512
515
  export const LoadingDataTableComponent = memo(
@@ -550,11 +553,35 @@ export const LoadingDataTableComponent = memo(
550
553
  setPaginationState({ pageIndex: 0, pageSize: props.pageSize });
551
554
  }
552
555
 
556
+ const resetLazyPage = useEvent(() => {
557
+ if (props.lazy) {
558
+ setPaginationState((state) =>
559
+ state.pageIndex === 0 ? state : { ...state, pageIndex: 0 },
560
+ );
561
+ }
562
+ });
563
+
564
+ const handleSortingChange: OnChangeFn<SortingState> = useEvent(
565
+ (updater) => {
566
+ setSorting(updater);
567
+ resetLazyPage();
568
+ },
569
+ );
570
+ const handleFiltersChange: SetFilters = useEvent((updater) => {
571
+ setFilters(updater);
572
+ resetLazyPage();
573
+ });
574
+ const handleSearchQueryChange = useEvent((query: string) => {
575
+ setSearchQuery(query);
576
+ resetLazyPage();
577
+ });
578
+
553
579
  // Data loading
554
580
  const { data, error, isPending, isFetching } = useAsyncData<{
555
581
  rows: T[];
556
582
  rawRows?: T[];
557
583
  totalRows: number | TooManyRows;
584
+ hasNextPage?: boolean;
558
585
  cellStyles: CellStyleState | undefined | null;
559
586
  cellHoverTexts?: Record<string, Record<string, string | null>> | null;
560
587
  }>(async () => {
@@ -571,6 +598,7 @@ export const LoadingDataTableComponent = memo(
571
598
  let tableData = props.data;
572
599
  let rawTableData: TableData<T> | undefined | null = props.rawData;
573
600
  let totalRows = props.totalRows;
601
+ let hasNextPage: boolean | undefined;
574
602
  let cellStyles = props.cellStyles;
575
603
  let cellHoverTexts = props.cellHoverTexts;
576
604
 
@@ -614,6 +642,7 @@ export const LoadingDataTableComponent = memo(
614
642
  tableData = searchResults.data;
615
643
  rawTableData = searchResults.raw_data;
616
644
  totalRows = searchResults.total_rows;
645
+ hasNextPage = searchResults.has_next_page ?? undefined;
617
646
  cellStyles = searchResults.cell_styles || {};
618
647
  cellHoverTexts = searchResults.cell_hover_texts || {};
619
648
  }
@@ -626,6 +655,7 @@ export const LoadingDataTableComponent = memo(
626
655
  rows: tableData,
627
656
  rawRows: rawData,
628
657
  totalRows: totalRows,
658
+ hasNextPage,
629
659
  cellStyles,
630
660
  cellHoverTexts,
631
661
  };
@@ -775,13 +805,16 @@ export const LoadingDataTableComponent = memo(
775
805
  rawData={data?.rawRows}
776
806
  columnSummaries={columnSummaries}
777
807
  sorting={sorting}
778
- setSorting={setSorting}
808
+ setSorting={handleSortingChange}
779
809
  searchQuery={searchQuery}
780
- setSearchQuery={setSearchQuery}
810
+ setSearchQuery={handleSearchQueryChange}
781
811
  filters={filters}
782
- setFilters={setFilters}
812
+ setFilters={handleFiltersChange}
783
813
  reloading={isFetching && !isPending}
784
814
  totalRows={data?.totalRows ?? props.totalRows}
815
+ hasNextPage={
816
+ props.lazy ? !error && (data?.hasNextPage ?? false) : undefined
817
+ }
785
818
  sizeBytes={sizeBytes}
786
819
  sizeBytesIsLoading={sizeBytesIsLoading}
787
820
  paginationState={paginationState}
@@ -831,6 +864,7 @@ const DataTableComponent = ({
831
864
  data,
832
865
  rawData,
833
866
  totalRows,
867
+ hasNextPage,
834
868
  sizeBytes,
835
869
  sizeBytesIsLoading,
836
870
  maxColumns,
@@ -1130,7 +1164,8 @@ const DataTableComponent = ({
1130
1164
  <>
1131
1165
  {/* When the totalRows is "too_many" and the pageSize is the same as the
1132
1166
  * number of rows, we are likely displaying all the data (could be more, but we don't know the total). */}
1133
- {totalRows === TOO_MANY_ROWS &&
1167
+ {hasNextPage === undefined &&
1168
+ totalRows === TOO_MANY_ROWS &&
1134
1169
  paginationState.pageSize === data.length && (
1135
1170
  <Banner className="mb-1 rounded">
1136
1171
  Previewing the first {paginationState.pageSize} rows.
@@ -1168,6 +1203,7 @@ const DataTableComponent = ({
1168
1203
  maxHeight={maxHeight}
1169
1204
  sorting={sorting}
1170
1205
  totalRows={totalRows}
1206
+ hasNextPage={hasNextPage}
1171
1207
  sizeBytes={sizeBytes}
1172
1208
  sizeBytesIsLoading={sizeBytesIsLoading}
1173
1209
  totalColumns={totalColumns}
@@ -10,6 +10,7 @@ import {
10
10
  render,
11
11
  screen,
12
12
  waitFor,
13
+ within,
13
14
  } from "@testing-library/react";
14
15
  import { Provider } from "jotai";
15
16
  import { useState } from "react";
@@ -540,6 +541,275 @@ describe("table pagination and static notebook control suppression", () => {
540
541
  mockIsStatic.mockReturnValue(false);
541
542
  });
542
543
 
544
+ it("preserves the preview banner for ordinary paginated tables with unknown totals", async () => {
545
+ const props = makeProps();
546
+ render(
547
+ <Wrapper>
548
+ <LoadingDataTableComponent {...props} totalRows="too_many" />
549
+ </Wrapper>,
550
+ );
551
+ expect(
552
+ await screen.findByText("Previewing the first 10 rows."),
553
+ ).toBeInTheDocument();
554
+ });
555
+
556
+ it("pages lazy results with unknown totals and resets search, sorting, and filters to page one", async () => {
557
+ const props = makeProps();
558
+ const search = vi
559
+ .fn()
560
+ .mockImplementation(
561
+ async (request: {
562
+ page_number: number;
563
+ query?: string;
564
+ sort?: { by: string; descending: boolean }[];
565
+ }) => ({
566
+ data: [
567
+ { id: request.page_number + 1, name: request.query || "item" },
568
+ ],
569
+ total_rows: "too_many",
570
+ has_next_page: request.page_number === 0,
571
+ }),
572
+ );
573
+ render(
574
+ <Wrapper>
575
+ <LoadingDataTableComponent
576
+ {...props}
577
+ lazy={true}
578
+ totalRows="too_many"
579
+ selection={null}
580
+ showColumnSummaries={false}
581
+ search={search}
582
+ />
583
+ </Wrapper>,
584
+ );
585
+ await waitFor(() =>
586
+ expect(screen.getByTestId("next-page-button")).toBeEnabled(),
587
+ );
588
+ expect(screen.getByText("Unknown, 2 columns")).toBeInTheDocument();
589
+ expect(screen.queryByTestId("last-page-button")).not.toBeInTheDocument();
590
+ expect(screen.queryByText(/Previewing the first/)).not.toBeInTheDocument();
591
+ fireEvent.click(screen.getByTestId("next-page-button"));
592
+ await waitFor(() =>
593
+ expect(search).toHaveBeenLastCalledWith(
594
+ expect.objectContaining({ page_number: 1 }),
595
+ ),
596
+ );
597
+ await waitFor(() =>
598
+ expect(screen.getByTestId("next-page-button")).toBeDisabled(),
599
+ );
600
+ fireEvent.click(screen.getByTestId("previous-page-button"));
601
+ await waitFor(() =>
602
+ expect(screen.getByTestId("next-page-button")).toBeEnabled(),
603
+ );
604
+ fireEvent.click(screen.getByTestId("next-page-button"));
605
+ await waitFor(() =>
606
+ expect(screen.getByTestId("next-page-button")).toBeDisabled(),
607
+ );
608
+ search.mockClear();
609
+ fireEvent.change(screen.getByPlaceholderText("Search..."), {
610
+ target: { value: "new" },
611
+ });
612
+ await waitFor(() =>
613
+ expect(search).toHaveBeenCalledWith(
614
+ expect.objectContaining({ query: "new", page_number: 0 }),
615
+ ),
616
+ );
617
+ expect(
618
+ search.mock.calls.every(([request]) => request.page_number === 0),
619
+ ).toBe(true);
620
+ await waitFor(() =>
621
+ expect(screen.getByTestId("next-page-button")).toBeEnabled(),
622
+ );
623
+ fireEvent.click(screen.getByTestId("next-page-button"));
624
+ await waitFor(() =>
625
+ expect(screen.getByTestId("next-page-button")).toBeDisabled(),
626
+ );
627
+ search.mockClear();
628
+ fireEvent.click(
629
+ within(screen.getByRole("columnheader", { name: /name/ })).getByRole(
630
+ "button",
631
+ { name: "Sort column ascending" },
632
+ ),
633
+ );
634
+ await waitFor(() =>
635
+ expect(search).toHaveBeenCalledWith(
636
+ expect.objectContaining({
637
+ page_number: 0,
638
+ sort: [{ by: "name", descending: false }],
639
+ }),
640
+ ),
641
+ );
642
+ expect(
643
+ search.mock.calls.every(([request]) => request.page_number === 0),
644
+ ).toBe(true);
645
+ await waitFor(() =>
646
+ expect(screen.getByTestId("next-page-button")).toBeEnabled(),
647
+ );
648
+ fireEvent.click(screen.getByTestId("next-page-button"));
649
+ await waitFor(() =>
650
+ expect(search).toHaveBeenLastCalledWith(
651
+ expect.objectContaining({ page_number: 1 }),
652
+ ),
653
+ );
654
+ await waitFor(() =>
655
+ expect(screen.getByTestId("next-page-button")).toBeDisabled(),
656
+ );
657
+ search.mockClear();
658
+ const columnMenu = within(
659
+ screen.getByRole("columnheader", { name: /name/ }),
660
+ ).getByRole("button", { name: "Column options" });
661
+ fireEvent.keyDown(columnMenu, { key: "Enter" });
662
+ fireEvent.click(await screen.findByRole("menuitem", { name: "Filter" }));
663
+ fireEvent.change(screen.getByPlaceholderText("Text…"), {
664
+ target: { value: "item" },
665
+ });
666
+ fireEvent.click(screen.getByLabelText("Apply filter"));
667
+ await waitFor(() =>
668
+ expect(search).toHaveBeenCalledWith(
669
+ expect.objectContaining({
670
+ page_number: 0,
671
+ filters: expect.objectContaining({ children: expect.any(Array) }),
672
+ }),
673
+ ),
674
+ );
675
+ expect(
676
+ search.mock.calls.every(([request]) => request.page_number === 0),
677
+ ).toBe(true);
678
+ });
679
+
680
+ it.each(["success", "error"] as const)(
681
+ "ignores a stale lazy page %s after the query changes",
682
+ async (outcome) => {
683
+ const props = makeProps();
684
+ const initial = {
685
+ data: [{ id: 1, name: "initial-page" }],
686
+ total_rows: "too_many" as const,
687
+ has_next_page: true,
688
+ };
689
+ const oldPage = new Deferred<typeof initial>();
690
+ const newQuery = new Deferred<typeof initial>();
691
+ const search = vi
692
+ .fn()
693
+ .mockResolvedValueOnce(initial)
694
+ .mockReturnValueOnce(oldPage.promise)
695
+ .mockReturnValueOnce(newQuery.promise);
696
+ render(
697
+ <Wrapper>
698
+ <LoadingDataTableComponent
699
+ {...props}
700
+ lazy={true}
701
+ totalRows="too_many"
702
+ selection={null}
703
+ showColumnSummaries={false}
704
+ search={search}
705
+ />
706
+ </Wrapper>,
707
+ );
708
+ await screen.findByText("initial-page");
709
+ fireEvent.click(screen.getByTestId("next-page-button"));
710
+ await waitFor(() =>
711
+ expect(search).toHaveBeenLastCalledWith(
712
+ expect.objectContaining({ page_number: 1 }),
713
+ ),
714
+ );
715
+ expect(screen.getByTestId("next-page-button")).toBeDisabled();
716
+
717
+ fireEvent.change(screen.getByPlaceholderText("Search..."), {
718
+ target: { value: "current" },
719
+ });
720
+ await waitFor(() =>
721
+ expect(search).toHaveBeenLastCalledWith(
722
+ expect.objectContaining({ query: "current", page_number: 0 }),
723
+ ),
724
+ );
725
+ await act(async () => {
726
+ newQuery.resolve({
727
+ data: [{ id: 2, name: "latest-page" }],
728
+ total_rows: "too_many",
729
+ has_next_page: false,
730
+ });
731
+ });
732
+ await screen.findByText("latest-page");
733
+
734
+ await act(async () => {
735
+ if (outcome === "success") {
736
+ oldPage.resolve({
737
+ ...initial,
738
+ data: [{ id: 3, name: "stale-result" }],
739
+ });
740
+ } else {
741
+ oldPage.reject(new Error("stale-page-error"));
742
+ }
743
+ });
744
+ expect(screen.getByText("latest-page")).toBeInTheDocument();
745
+ expect(screen.queryByText("stale-result")).not.toBeInTheDocument();
746
+ expect(screen.queryByText("stale-page-error")).not.toBeInTheDocument();
747
+ expect(screen.getByTestId("previous-page-button")).toBeDisabled();
748
+ expect(screen.getByTestId("next-page-button")).toBeDisabled();
749
+ },
750
+ );
751
+
752
+ it("recovers from a failed lazy page by navigating back", async () => {
753
+ const props = makeProps();
754
+ const initial = {
755
+ data: [{ id: 1, name: "initial-page" }],
756
+ total_rows: "too_many" as const,
757
+ has_next_page: true,
758
+ };
759
+ const failedPage = new Deferred<typeof initial>();
760
+ const search = vi
761
+ .fn()
762
+ .mockResolvedValueOnce(initial)
763
+ .mockReturnValueOnce(failedPage.promise)
764
+ .mockResolvedValueOnce({
765
+ ...initial,
766
+ data: [{ id: 1, name: "recovered-page" }],
767
+ })
768
+ .mockResolvedValueOnce({
769
+ ...initial,
770
+ data: [{ id: 2, name: "last-page" }],
771
+ has_next_page: false,
772
+ });
773
+ render(
774
+ <Wrapper>
775
+ <LoadingDataTableComponent
776
+ {...props}
777
+ lazy={true}
778
+ totalRows="too_many"
779
+ selection={null}
780
+ showColumnSummaries={false}
781
+ search={search}
782
+ />
783
+ </Wrapper>,
784
+ );
785
+ await screen.findByText("initial-page");
786
+ fireEvent.click(screen.getByTestId("next-page-button"));
787
+ await waitFor(() =>
788
+ expect(search).toHaveBeenLastCalledWith(
789
+ expect.objectContaining({ page_number: 1 }),
790
+ ),
791
+ );
792
+ expect(screen.getByTestId("next-page-button")).toBeDisabled();
793
+ expect(screen.getByText("initial-page")).toBeInTheDocument();
794
+ await act(async () => {
795
+ failedPage.reject(new Error("page-fetch-failed"));
796
+ });
797
+ await screen.findByText("page-fetch-failed");
798
+ expect(screen.getByTestId("next-page-button")).toBeDisabled();
799
+ expect(screen.getByTestId("previous-page-button")).toBeEnabled();
800
+
801
+ fireEvent.click(screen.getByTestId("previous-page-button"));
802
+ await screen.findByText("recovered-page");
803
+ expect(search).toHaveBeenLastCalledWith(
804
+ expect.objectContaining({ page_number: 0 }),
805
+ );
806
+ expect(screen.queryByText("page-fetch-failed")).not.toBeInTheDocument();
807
+ expect(screen.getByTestId("next-page-button")).toBeEnabled();
808
+ fireEvent.click(screen.getByTestId("next-page-button"));
809
+ await screen.findByText("last-page");
810
+ expect(screen.getByTestId("next-page-button")).toBeDisabled();
811
+ });
812
+
543
813
  it("requests only the first page with the new configured page size", async () => {
544
814
  const props = makeProps();
545
815
  const view = (pageSize: number) => (