diff --git a/common/changes/@itwin/imodel-browser-react/alex-itwin-tableview-fixes_2026-06-16-17-50.json b/common/changes/@itwin/imodel-browser-react/alex-itwin-tableview-fixes_2026-06-16-17-50.json new file mode 100644 index 00000000..e0016f44 --- /dev/null +++ b/common/changes/@itwin/imodel-browser-react/alex-itwin-tableview-fixes_2026-06-16-17-50.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/imodel-browser-react", + "comment": "Prevent concurrent iTwin API fetches", + "type": "patch" + } + ], + "packageName": "@itwin/imodel-browser-react" +} \ No newline at end of file diff --git a/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx b/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx index 900f83a1..6f8ad605 100644 --- a/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/IModelGrid.stories.tsx @@ -40,7 +40,29 @@ export const IModelGrid = (props: IModelGridProps) => ( export default { title: "imodel-browser/IModelGrid", component: IModelGrid, - argTypes: accessTokenArgTypes, + argTypes: { + ...accessTokenArgTypes, + requestType: { + options: ["all", "recents", "favorites"], + mapping: { + all: "", + recents: "recents", + favorites: "favorites", + }, + control: { + type: "radio", + }, + }, + viewMode: { + options: ["tile", "cells"], + control: { + type: "radio", + }, + }, + }, + args: { + requestType: "all", + }, excludeStories: ["IModelGrid"], } as Meta; diff --git a/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx b/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx index 3cf45a61..89ec6864 100644 --- a/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx @@ -41,6 +41,26 @@ export default { component: ITwinGrid, argTypes: { accessToken, + requestType: { + options: ["all", "recents", "favorites"], + mapping: { + all: "", + recents: "recents", + favorites: "favorites", + }, + control: { + type: "radio", + }, + }, + viewMode: { + options: ["tile", "cells"], + control: { + type: "radio", + }, + }, + }, + args: { + requestType: "all", }, excludeStories: ["ITwinGrid"], } as Meta; diff --git a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx index c5584528..d56dcb33 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/IModelGridMUI.stories.tsx @@ -38,7 +38,29 @@ export const IModelGridMUI = (props: IModelGridMUIProps) => ( export default { title: "imodel-browser/IModelGridMUI", component: IModelGridMUI, - argTypes: accessTokenArgTypes, + argTypes: { + ...accessTokenArgTypes, + requestType: { + options: ["all", "recents", "favorites"], + mapping: { + all: "", + recents: "recents", + favorites: "favorites", + }, + control: { + type: "radio", + }, + }, + viewMode: { + options: ["tile", "cells"], + control: { + type: "radio", + }, + }, + }, + args: { + requestType: "all", + }, excludeStories: ["IModelGridMUI"], } as Meta; diff --git a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx index 8a9acff1..6c1006f7 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx @@ -405,6 +405,26 @@ export default { component: ITwinGrid, argTypes: { accessToken, + viewMode: { + options: ["tile", "cells"], + control: { + type: "radio", + }, + }, + requestType: { + options: ["all", "recents", "favorites"], + mapping: { + all: "", + recents: "recents", + favorites: "favorites", + }, + control: { + type: "radio", + }, + }, + }, + args: { + requestType: "all", }, excludeStories: ["ITwinGrid"], } as Meta; diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts index 8c0f4f3d..e1f77cfe 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts @@ -2,7 +2,7 @@ * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import { renderHook } from "@testing-library/react-hooks"; +import { act, renderHook } from "@testing-library/react-hooks"; import { type ResponseComposition, type RestContext, @@ -280,4 +280,78 @@ describe("useITwinData hook", () => { expect(urlWatcher).toHaveBeenCalledTimes(2); }); }); + + describe("fetchMore", () => { + it("ignores fetchMore until the initial request settles", async () => { + const requestedPages: (string | null)[] = []; + server.use( + rest.get("https://api.bentley.com/itwins/", (req, res, ctx) => { + const skip = req.url.searchParams.get("$skip"); + requestedPages.push(skip); + return res( + ctx.status(200), + ctx.json({ + iTwins: [{ id: `id-${skip}`, displayName: `name-${skip}` }], + }) + ); + }) + ); + + const { result, waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken }) + ); + + // fetchMore is available immediately but must be ignored until the first request completes. + act(() => { + result.current.fetchMore?.(); + result.current.fetchMore?.(); + }); + + await waitForNextUpdate(); + + expect(result.current.status).toEqual(DataStatus.Complete); + // Only the first page should have been requested + expect(requestedPages).toEqual(["0"]); + expect(result.current.iTwins).toEqual([ + { id: "id-0", displayName: "name-0" }, + ]); + }); + + it("fetches the next page once the previous request has settled", async () => { + const firstPage = Array.from({ length: 100 }, (_, i) => ({ + id: `first-${i}`, + displayName: `first-${i}`, + })); + const secondPage = [{ id: "second-0", displayName: "second-0" }]; + server.use( + rest.get("https://api.bentley.com/itwins/", (req, res, ctx) => { + const skip = req.url.searchParams.get("$skip"); + return res( + ctx.status(200), + ctx.json({ iTwins: skip === "0" ? firstPage : secondPage }) + ); + }) + ); + + const { result, waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken }) + ); + + await waitForNextUpdate(); + expect(result.current.status).toEqual(DataStatus.Complete); + expect(result.current.iTwins).toHaveLength(100); + expect(result.current.fetchMore).toBeDefined(); + + act(() => { + result.current.fetchMore?.(); + }); + await waitForNextUpdate(); + + expect(result.current.iTwins).toHaveLength(101); + expect(result.current.iTwins).toContainEqual({ + id: "second-0", + displayName: "second-0", + }); + }); + }); }); diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts index e5a5285a..7e4c503a 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts @@ -51,10 +51,11 @@ export const useITwinData = ({ setProjects([]); setPage(0); setMorePages(true); - fetchingMoreRef.current = false; + fetchingMoreRef.current = true; }, []); - const fetchingMoreRef = React.useRef(false); + // We start in a fetching state + const fetchingMoreRef = React.useRef(true); const fetchMore = React.useCallback(() => { if (fetchingMoreRef.current) { return;