From a618cde136977fe6e1ce812b836326e24de0ab7d Mon Sep 17 00:00:00 2001 From: Lukasz Kokot Date: Tue, 18 Aug 2026 14:15:41 -0400 Subject: [PATCH 01/11] Hold the iTwin grid's fetch state in a single object The status, the iTwins and the has-more flag were three useState calls set from the same async fetch callback. Nothing guarantees they land in one render, so a render could show Complete beside the previous query's iTwins. Initial status is now fetching rather than undefined, which is what postProcessCallback receives as its second argument on the first render. --- .../src/containers/ITwinGrid/useITwinData.ts | 98 +++++++++++-------- 1 file changed, 58 insertions(+), 40 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts index 507e0b43..e3bd86b7 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts @@ -29,6 +29,22 @@ export interface ProjectDataHookOptions { const PAGE_SIZE = 100; +/** Held together so a render can never show one query's status beside another query's iTwins. */ +interface FetchState { + status: DataStatus; + iTwins: ITwinFull[]; + hasMore: boolean; +} + +const isClientSideFiltered = (requestType: string) => + ["favorites", "recents"].includes(requestType); + +const startingOver = (): FetchState => ({ + status: DataStatus.Fetching, + iTwins: [], + hasMore: true, +}); + export const useITwinData = ({ requestType = "", iTwinSubClass = "Project", @@ -42,19 +58,15 @@ export const useITwinData = ({ const logger = useLogger(); const data = apiOverrides?.data; const serverEnvironmentPrefix = apiOverrides?.serverEnvironmentPrefix; - const [projects, setProjects] = React.useState([]); - const [status, setStatus] = React.useState(); const [totalCount, setTotalCount] = React.useState(); - const filteredProjects = useITwinFilter(projects, filterOptions); const [page, setPage] = React.useState(0); - const [morePages, setMorePages] = React.useState(true); + const [fetchState, setFetchState] = React.useState(startingOver); + const filteredProjects = useITwinFilter(fetchState.iTwins, filterOptions); const resetData = React.useCallback(() => { - setStatus(DataStatus.Fetching); - setProjects([]); + setFetchState(startingOver()); setTotalCount(undefined); setPage(0); - setMorePages(true); fetchingMoreRef.current = true; lastPageFailedRef.current = false; }, []); @@ -86,19 +98,16 @@ export const useITwinData = ({ const activeRequestRef = React.useRef(undefined); - const morePagesRef = React.useRef(morePages); + const morePagesRef = React.useRef(fetchState.hasMore); React.useEffect(() => { - morePagesRef.current = morePages; - }, [morePages]); + morePagesRef.current = fetchState.hasMore; + }, [fetchState.hasMore]); React.useEffect(() => { // If filter changes but we already have all the data for favorites or recents, // let client side filtering do its job, otherwise, refetch from scratch. // Use ref so "morePages" changes itself does not trigger the effect. - if ( - morePagesRef.current || - !["favorites", "recents"].includes(requestType) - ) { + if (morePagesRef.current || !isClientSideFiltered(requestType)) { resetData(); } }, [filterOptions, requestType, resetData]); @@ -117,38 +126,46 @@ export const useITwinData = ({ ]); React.useEffect(() => { - if (!morePages) { + if (!fetchState.hasMore) { return; } if (data) { - setProjects(data); - setStatus(DataStatus.Complete); - setMorePages(false); + setFetchState((state) => ({ + ...state, + status: DataStatus.Complete, + iTwins: data, + hasMore: false, + })); return; } if (!accessToken) { - setStatus(DataStatus.TokenRequired); - setProjects([]); + setFetchState((state) => ({ + ...state, + status: DataStatus.TokenRequired, + iTwins: [], + })); return; } if (page === 0) { - setStatus(DataStatus.Fetching); + setFetchState((state) => + state.status === DataStatus.Fetching + ? state + : { ...state, status: DataStatus.Fetching } + ); } const requestId = Symbol(); activeRequestRef.current = requestId; const abortController = new AbortController(); - const endpoint = ["favorites", "recents"].includes(requestType) - ? requestType - : ""; + const endpoint = isClientSideFiltered(requestType) ? requestType : ""; const resolvedITwinSubClass = iTwinSubClass === "All" ? "" : iTwinSubClass; const subClass = `?subClass=${resolvedITwinSubClass}`; const paging = `&$skip=${page * PAGE_SIZE}&$top=${PAGE_SIZE}`; const search = - ["favorites", "recents"].includes(requestType) || !filterOptions + isClientSideFiltered(requestType) || !filterOptions ? "" : `&$search=${encodeURIComponent(String(filterOptions).trim())}`; const orderby = - ["favorites", "recents"].includes(requestType) || !orderbyOptions + isClientSideFiltered(requestType) || !orderbyOptions ? "" : `&$orderby=${encodeURIComponent(String(orderbyOptions).trim())}`; @@ -187,15 +204,15 @@ export const useITwinData = ({ if (totalCountHeader !== null) { setTotalCount(Number(totalCountHeader)); } - setStatus(DataStatus.Complete); fetchingMoreRef.current = false; requestType === "favorites" && resetShouldRefetchFavorites?.(); - if (result.iTwins.length !== PAGE_SIZE) { - setMorePages(false); - } - setProjects((projects) => - page === 0 ? result.iTwins : [...projects, ...result.iTwins] - ); + setFetchState((state) => ({ + ...state, + status: DataStatus.Complete, + iTwins: + page === 0 ? result.iTwins : [...state.iTwins, ...result.iTwins], + hasMore: result.iTwins.length === PAGE_SIZE, + })); }; makeFetchRequest().catch((e) => { @@ -203,12 +220,13 @@ export const useITwinData = ({ // Superseded or aborted, not a failure worth reporting. return; } - if (page === 0) { - setProjects([]); - } - setStatus(DataStatus.FetchFailed); fetchingMoreRef.current = false; lastPageFailedRef.current = true; + setFetchState((state) => ({ + ...state, + status: DataStatus.FetchFailed, + iTwins: page === 0 ? [] : state.iTwins, + })); logger.logError("Failed to fetch iTwins", e); }); return () => { @@ -223,7 +241,7 @@ export const useITwinData = ({ filterOptions, orderbyOptions, page, - morePages, + fetchState.hasMore, refetchCount, retryCount, iTwinSubClass, @@ -233,9 +251,9 @@ export const useITwinData = ({ ]); return { iTwins: filteredProjects, - status, + status: fetchState.status, totalCount, - fetchMore: morePages ? fetchMore : undefined, + fetchMore: fetchState.hasMore ? fetchMore : undefined, refetchITwins, }; }; From 993531ab39554c3e5844aae2fd2238c685df6709 Mon Sep 17 00:00:00 2001 From: Lukasz Kokot Date: Tue, 18 Aug 2026 14:17:01 -0400 Subject: [PATCH 02/11] Add onDataStateChange to ITwinGrid The grid's fetching state only escaped through postProcessCallback, a hook for transforming the iTwin array that consumers were reading a status out of. It runs during render, so a consumer had to work out which request a status belonged to from its own props. The new prop reports from an effect, and carries the query it belongs to. It also carries hasMore, since complete means the page landed rather than all the data being present, and the error behind error_fetchFailed, which until now only reached the logger. The query names the subclass as well as the request type, filter text and ordering, so that switching subclass is not mistaken for a refresh of the previous one. Partially solves AB#2114177 --- ...twin-grid-data-state_2026-08-17-00-00.json | 15 ++ .../containers/ITwinGrid/ITwinGrid.test.tsx | 10 + .../src/containers/ITwinGrid/ITwinGrid.tsx | 8 + .../containers/ITwinGrid/useITwinData.test.ts | 217 +++++++++++++++++- .../src/containers/ITwinGrid/useITwinData.ts | 75 +++++- .../ITwinGrid/ITwinGridMUI.test.tsx | 48 ++++ .../mui/containers/ITwinGrid/ITwinGridMUI.tsx | 2 + .../modules/imodel-browser/src/mui/index.ts | 2 + packages/modules/imodel-browser/src/types.ts | 15 ++ 9 files changed, 384 insertions(+), 8 deletions(-) create mode 100644 common/changes/@itwin/imodel-browser-react/lk-itwin-grid-data-state_2026-08-17-00-00.json create mode 100644 packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.test.tsx diff --git a/common/changes/@itwin/imodel-browser-react/lk-itwin-grid-data-state_2026-08-17-00-00.json b/common/changes/@itwin/imodel-browser-react/lk-itwin-grid-data-state_2026-08-17-00-00.json new file mode 100644 index 00000000..6abf99af --- /dev/null +++ b/common/changes/@itwin/imodel-browser-react/lk-itwin-grid-data-state_2026-08-17-00-00.json @@ -0,0 +1,15 @@ +{ + "changes": [ + { + "packageName": "@itwin/imodel-browser-react", + "comment": "Add `onDataStateChange` to `ITwinGrid`, reporting the query, status, iTwins, `hasMore` and any error each time the grid's data state changes", + "type": "minor" + }, + { + "packageName": "@itwin/imodel-browser-react", + "comment": "Report `fetching` rather than `undefined` for the iTwin grid's status on the first render, which `postProcessCallback` receives as its second argument", + "type": "minor" + } + ], + "packageName": "@itwin/imodel-browser-react" +} diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.test.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.test.tsx index 288fd506..9bfd6683 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.test.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.test.tsx @@ -66,6 +66,16 @@ describe("ITwinGrid", () => { expect(wrapper.getAllByRole("row").length).toEqual(3); // First row is header }); + it("should hand onDataStateChange to the data hook", () => { + const onDataStateChange = jest.fn(); + + render(); + + expect(useITwinData.useITwinData).toHaveBeenCalledWith( + expect.objectContaining({ onDataStateChange }) + ); + }); + it("should not refetch iTwins favorites when component rerenders", async () => { // Arrange jest.spyOn(useITwinData, "useITwinData").mockReturnValue({ diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx index 1d6bc0c3..c6f23b01 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/ITwinGrid.tsx @@ -16,6 +16,7 @@ import { ApiOverrides, DataStatus, ITwinCellOverrides, + ITwinDataState, ITwinFilterOptions, ITwinFull, ITwinSubClass, @@ -106,6 +107,11 @@ export interface ITwinGridProps { fetchStatus: DataStatus | undefined, totalCount: number | undefined ) => ITwinFull[]; + /** + * Called with the grid's data state when it changes: once for each query, then for each result it + * returns. Does not need to be memoized. + */ + onDataStateChange?: (state: ITwinDataState) => void; /**iTwin view mode */ viewMode?: ViewType; /** Overrides for cell rendering in cells viewMode */ @@ -141,6 +147,7 @@ const ITwinGridInternal = ({ tileOverrides, useIndividualState, postProcessCallback, + onDataStateChange, viewMode, cellOverrides, className, @@ -191,6 +198,7 @@ const ITwinGridInternal = ({ orderbyOptions, shouldRefetchFavorites, resetShouldRefetchFavorites, + onDataStateChange, }); const iTwins = React.useMemo( 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 8302db4e..4802b372 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts @@ -10,9 +10,9 @@ import { rest, } from "msw"; -import { deferred, responseFor } from "../../tests/helpers"; +import { deferred, ids, responseFor } from "../../tests/helpers"; import { server } from "../../tests/mocks/server"; -import { DataStatus } from "../../types"; +import { DataStatus, ITwinDataState } from "../../types"; import { useITwinData } from "./useITwinData"; describe("useITwinData hook", () => { @@ -827,4 +827,217 @@ describe("useITwinData hook", () => { expect(requests).toEqual(2); }); }); + + describe("onDataStateChange", () => { + const reportsOf = (states: ITwinDataState[]) => + states.map((state) => [ + state.query.filterText, + state.status, + state.iTwins.map(ids), + ]); + + it("reports the query it is fetching, then the result of that query", async () => { + const states: ITwinDataState[] = []; + + const { waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken, onDataStateChange: (s) => states.push(s) }) + ); + await waitForNextUpdate(); + + expect(reportsOf(states)).toEqual([ + ["", DataStatus.Fetching, []], + ["", DataStatus.Complete, ["my1"]], + ]); + expect(states[0].query).toEqual({ + requestType: "", + filterText: "", + iTwinSubClass: "Project", + orderby: undefined, + }); + }); + + it("never reports a settled status against a query that has not run", async () => { + const states: ITwinDataState[] = []; + const onDataStateChange = (state: ITwinDataState) => states.push(state); + + const { rerender, waitForNextUpdate } = renderHook< + Parameters, + ReturnType + >((args) => useITwinData(...args), { + initialProps: [{ accessToken, onDataStateChange }], + }); + await waitForNextUpdate(); + + rerender([{ accessToken, onDataStateChange, filterOptions: "searched" }]); + await waitForNextUpdate(); + + expect(reportsOf(states)).toEqual([ + ["", DataStatus.Fetching, []], + ["", DataStatus.Complete, ["my1"]], + ["searched", DataStatus.Fetching, []], + ["searched", DataStatus.Complete, ["mySearched1"]], + ]); + }); + + it("reports the new query for a filter change that only client side filtering handles", async () => { + const favorites = [ + { id: "fav1", displayName: "alpha" }, + { id: "fav2", displayName: "beta" }, + ]; + server.use( + rest.get("https://api.bentley.com/itwins/favorites", (_req, res, ctx) => + res(ctx.status(200), ctx.json({ iTwins: favorites })) + ) + ); + const states: ITwinDataState[] = []; + const onDataStateChange = (state: ITwinDataState) => states.push(state); + + const { rerender, waitFor, waitForNextUpdate } = renderHook< + Parameters, + ReturnType + >((args) => useITwinData(...args), { + initialProps: [ + { accessToken, requestType: "favorites" as const, onDataStateChange }, + ], + }); + await waitForNextUpdate(); + + rerender([ + { + accessToken, + requestType: "favorites" as const, + onDataStateChange, + filterOptions: "alpha", + }, + ]); + await waitFor(() => states.length >= 3); + + expect(reportsOf(states)).toEqual([ + ["", DataStatus.Fetching, []], + ["", DataStatus.Complete, ["fav1", "fav2"]], + ["alpha", DataStatus.Complete, ["fav1"]], + ]); + }); + + it("reports a subclass change as a different query", async () => { + server.use( + rest.get("https://api.bentley.com/itwins/", (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + iTwins: [{ id: req.url.searchParams.get("subClass") || "none" }], + }) + ) + ) + ); + const states: ITwinDataState[] = []; + const onDataStateChange = (state: ITwinDataState) => states.push(state); + + const { rerender, waitForNextUpdate } = renderHook< + Parameters, + ReturnType + >((args) => useITwinData(...args), { + initialProps: [{ accessToken, onDataStateChange }], + }); + await waitForNextUpdate(); + + rerender([ + { accessToken, onDataStateChange, iTwinSubClass: "Asset" as const }, + ]); + await waitForNextUpdate(); + + expect( + states.map((state) => [ + state.query.iTwinSubClass, + state.status, + state.iTwins.map(ids), + ]) + ).toEqual([ + ["Project", DataStatus.Fetching, []], + ["Project", DataStatus.Complete, ["Project"]], + ["Asset", DataStatus.Fetching, []], + ["Asset", DataStatus.Complete, ["Asset"]], + ]); + }); + + it("reports hasMore until a partial page comes back", async () => { + const fullPage = Array.from({ length: 100 }, (_, i) => ({ + id: `first-${i}`, + })); + server.use( + rest.get("https://api.bentley.com/itwins/", (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + iTwins: + req.url.searchParams.get("$skip") === "0" + ? fullPage + : [{ id: "second-0" }], + }) + ) + ) + ); + const states: ITwinDataState[] = []; + + const { result, waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken, onDataStateChange: (s) => states.push(s) }) + ); + await waitForNextUpdate(); + expect(states[states.length - 1].hasMore).toBe(true); + + act(() => { + result.current.fetchMore?.(); + }); + await waitForNextUpdate(); + + const last = states[states.length - 1]; + expect(last.hasMore).toBe(false); + expect(last.iTwins).toHaveLength(101); + }); + + it("reports the failure that produced an error status", async () => { + server.use( + rest.get("https://api.bentley.com/itwins/", (_req, res, ctx) => + res(ctx.status(401), ctx.text("no soup for you")) + ) + ); + const states: ITwinDataState[] = []; + + const { waitForNextUpdate } = renderHook(() => + useITwinData({ accessToken, onDataStateChange: (s) => states.push(s) }) + ); + await waitForNextUpdate(); + + const last = states[states.length - 1]; + expect(last.status).toEqual(DataStatus.FetchFailed); + expect(last.error).toEqual(new Error("no soup for you")); + }); + + it("does not report again for a callback that changed identity", async () => { + const states: ITwinDataState[] = []; + + const { rerender, waitForNextUpdate } = renderHook< + Parameters, + ReturnType + >((args) => useITwinData(...args), { + initialProps: [ + { + accessToken, + onDataStateChange: (s: ITwinDataState) => states.push(s), + }, + ], + }); + await waitForNextUpdate(); + expect(states).toHaveLength(2); + + rerender([ + { + accessToken, + onDataStateChange: (s: ITwinDataState) => states.push(s), + }, + ]); + + expect(states).toHaveLength(2); + }); + }); }); diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts index e3bd86b7..cfb36cbf 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts @@ -9,6 +9,8 @@ import { AccessTokenProvider, ApiOverrides, DataStatus, + ITwinDataQuery, + ITwinDataState, ITwinFilterOptions, ITwinFull, ITwinSubClass, @@ -25,26 +27,42 @@ export interface ProjectDataHookOptions { orderbyOptions?: string; shouldRefetchFavorites?: boolean; resetShouldRefetchFavorites?: () => void; + onDataStateChange?: (state: ITwinDataState) => void; } const PAGE_SIZE = 100; -/** Held together so a render can never show one query's status beside another query's iTwins. */ interface FetchState { + query: ITwinDataQuery; status: DataStatus; iTwins: ITwinFull[]; hasMore: boolean; + error?: unknown; } const isClientSideFiltered = (requestType: string) => ["favorites", "recents"].includes(requestType); -const startingOver = (): FetchState => ({ +const startingOver = (query: ITwinDataQuery): FetchState => ({ + query, status: DataStatus.Fetching, iTwins: [], hasMore: true, + error: undefined, }); +const sameQuery = (a: ITwinDataQuery, b: ITwinDataQuery) => + a.requestType === b.requestType && + a.filterText === b.filterText && + a.iTwinSubClass === b.iTwinSubClass && + a.orderby === b.orderby; + +const hasStartedOver = (state: FetchState, query: ITwinDataQuery) => + sameQuery(state.query, query) && + state.status === DataStatus.Fetching && + state.iTwins.length === 0 && + state.hasMore; + export const useITwinData = ({ requestType = "", iTwinSubClass = "Project", @@ -54,17 +72,53 @@ export const useITwinData = ({ orderbyOptions, shouldRefetchFavorites, resetShouldRefetchFavorites, + onDataStateChange, }: ProjectDataHookOptions) => { const logger = useLogger(); const data = apiOverrides?.data; const serverEnvironmentPrefix = apiOverrides?.serverEnvironmentPrefix; const [totalCount, setTotalCount] = React.useState(); const [page, setPage] = React.useState(0); - const [fetchState, setFetchState] = React.useState(startingOver); + + const query = React.useMemo( + () => ({ + requestType, + filterText: filterOptions ?? "", + iTwinSubClass, + orderby: orderbyOptions, + }), + [requestType, filterOptions, iTwinSubClass, orderbyOptions] + ); + const [fetchState, setFetchState] = React.useState(() => + startingOver(query) + ); const filteredProjects = useITwinFilter(fetchState.iTwins, filterOptions); + const queryRef = React.useRef(query); + const onDataStateChangeRef = React.useRef(onDataStateChange); + React.useEffect(() => { + queryRef.current = query; + onDataStateChangeRef.current = onDataStateChange; + }); + + const dataState = React.useMemo( + () => ({ ...fetchState, iTwins: filteredProjects }), + [fetchState, filteredProjects] + ); + React.useEffect(() => { + // The new query has not reached the state below yet, so reporting now would pair it with the + // previous query's result. + if (sameQuery(dataState.query, query)) { + onDataStateChangeRef.current?.(dataState); + } + }, [dataState, query]); + const resetData = React.useCallback(() => { - setFetchState(startingOver()); + setFetchState((state) => + hasStartedOver(state, queryRef.current) + ? state + : startingOver(queryRef.current) + ); setTotalCount(undefined); setPage(0); fetchingMoreRef.current = true; @@ -109,8 +163,13 @@ export const useITwinData = ({ // Use ref so "morePages" changes itself does not trigger the effect. if (morePagesRef.current || !isClientSideFiltered(requestType)) { resetData(); + } else { + // The data already in hand answers the new query, but it is a new query all the same. + setFetchState((state) => + sameQuery(state.query, query) ? state : { ...state, query } + ); } - }, [filterOptions, requestType, resetData]); + }, [query, requestType, resetData]); React.useEffect(() => { // If any of the dependencies change, always restart the fetch from scratch. @@ -135,6 +194,7 @@ export const useITwinData = ({ status: DataStatus.Complete, iTwins: data, hasMore: false, + error: undefined, })); return; } @@ -143,6 +203,7 @@ export const useITwinData = ({ ...state, status: DataStatus.TokenRequired, iTwins: [], + error: undefined, })); return; } @@ -150,7 +211,7 @@ export const useITwinData = ({ setFetchState((state) => state.status === DataStatus.Fetching ? state - : { ...state, status: DataStatus.Fetching } + : { ...state, status: DataStatus.Fetching, error: undefined } ); } const requestId = Symbol(); @@ -212,6 +273,7 @@ export const useITwinData = ({ iTwins: page === 0 ? result.iTwins : [...state.iTwins, ...result.iTwins], hasMore: result.iTwins.length === PAGE_SIZE, + error: undefined, })); }; @@ -226,6 +288,7 @@ export const useITwinData = ({ ...state, status: DataStatus.FetchFailed, iTwins: page === 0 ? [] : state.iTwins, + error: e, })); logger.logError("Failed to fetch iTwins", e); }); diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.test.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.test.tsx new file mode 100644 index 00000000..a22ece2c --- /dev/null +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.test.tsx @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import "@testing-library/jest-dom"; + +import { Root } from "@stratakit/mui"; +import { render } from "@testing-library/react"; +import React from "react"; + +import * as useITwinData from "../../../containers/ITwinGrid/useITwinData"; +import { DataStatus } from "../../../types"; +import { ITwinGridMUI } from "./ITwinGridMUI"; + +// The real DataGrid needs a TextEncoder that jsdom does not provide. +jest.mock("@mui/x-data-grid", () => ({ + DataGrid: () =>
, +})); + +describe("ITwinGridMUI", () => { + beforeEach(() => { + jest.spyOn(useITwinData, "useITwinData").mockReturnValue({ + iTwins: [], + status: DataStatus.Complete, + totalCount: 0, + fetchMore: undefined, + refetchITwins: jest.fn(), + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should hand onDataStateChange to the data hook", () => { + const onDataStateChange = jest.fn(); + + render( + + + + ); + + expect(useITwinData.useITwinData).toHaveBeenCalledWith( + expect.objectContaining({ onDataStateChange }) + ); + }); +}); diff --git a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx index c5489bdd..c432634e 100644 --- a/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx +++ b/packages/modules/imodel-browser/src/mui/containers/ITwinGrid/ITwinGridMUI.tsx @@ -109,6 +109,7 @@ const ITwinGridMUIInternal = ({ tileOverrides, useIndividualState, postProcessCallback, + onDataStateChange, viewMode, tableOverrides, className, @@ -177,6 +178,7 @@ const ITwinGridMUIInternal = ({ orderbyOptions, shouldRefetchFavorites, resetShouldRefetchFavorites, + onDataStateChange, }); const iTwins = React.useMemo( diff --git a/packages/modules/imodel-browser/src/mui/index.ts b/packages/modules/imodel-browser/src/mui/index.ts index 0402e731..4e0c6903 100644 --- a/packages/modules/imodel-browser/src/mui/index.ts +++ b/packages/modules/imodel-browser/src/mui/index.ts @@ -57,6 +57,8 @@ export type { IModelFull, ITwinFull, ApiOverrides, + ITwinDataQuery, + ITwinDataState, ITwinFilterOptions, DataMode, IModelSortOptionsKeys, diff --git a/packages/modules/imodel-browser/src/types.ts b/packages/modules/imodel-browser/src/types.ts index dc946a06..0f656d5e 100644 --- a/packages/modules/imodel-browser/src/types.ts +++ b/packages/modules/imodel-browser/src/types.ts @@ -87,6 +87,21 @@ export enum DataStatus { ContextRequired = "error_contextRequired", } +export interface ITwinDataQuery { + requestType: "favorites" | "recents" | ""; + filterText: string; + iTwinSubClass: ITwinSubClass; + orderby?: string; +} + +export interface ITwinDataState { + query: ITwinDataQuery; + status: DataStatus; + iTwins: ITwinFull[]; + hasMore: boolean; + error?: unknown; +} + /** * Specifies the data management mode for the hook. * - "internal": Hook manages all data fetching, pagination, sorting, and filtering. Requires accessToken and iTwinId. From 0dab09a58fbb89cb0f879dd5fc39c31e9aee7c3f Mon Sep 17 00:00:00 2001 From: Lukasz Kokot Date: Tue, 18 Aug 2026 14:17:46 -0400 Subject: [PATCH 03/11] Declare the iTwin grid stories' action spy explicitly onDataStateChange matches the argTypesRegex in preview.tsx, so Storybook infers an action spy for it in every story that does not declare one. The grid calls the prop while it renders, which an inferred spy throws on, taking down every story in both files. --- .../apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx | 2 ++ .../storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx b/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx index f5f36557..f942057f 100644 --- a/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/ITwinGrid.stories.tsx @@ -23,6 +23,7 @@ import { } from "@itwin/itwinui-react"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import React, { PropsWithChildren } from "react"; +import { fn } from "storybook/test"; import { accessTokenArgTypes } from "../utils/storyHelp"; @@ -58,6 +59,7 @@ export default { args: { apiOverrides: { serverEnvironmentPrefix: "qa" }, requestType: "all", + onDataStateChange: fn(), }, excludeStories: ["ITwinGrid"], 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 022b3272..13942398 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx @@ -21,6 +21,7 @@ import Typography from "@mui/material/Typography"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import React from "react"; import { action } from "storybook/actions"; +import { fn } from "storybook/test"; import bridgeThumbnail from "../../utils/bridge.jpg"; import nightThumbnail from "../../utils/night.jpg"; @@ -428,6 +429,7 @@ export default { }, args: { requestType: "all", + onDataStateChange: fn(), }, excludeStories: ["ITwinGrid"], } as Meta; From 77d24925dad2480a96a20aafd849214c24df690b Mon Sep 17 00:00:00 2001 From: Lukasz Kokot Date: Tue, 18 Aug 2026 14:18:52 -0400 Subject: [PATCH 04/11] Add a story for onDataStateChange Logs each report with the time its query took to settle, and sends the report itself to the Actions panel. Searching the favorites or recents tab shows the case that is otherwise hard to see: a result reported with no fetch before it, answered from the iTwins already loaded. --- .../mui/ITwinGridMUI.stories.tsx | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) 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 13942398..6bfe96ad 100644 --- a/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx +++ b/packages/apps/storybook/src/imodel-browser/mui/ITwinGridMUI.stories.tsx @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { type IndividualITwinStateHook, + type ITwinDataQuery, + type ITwinDataState, type ITwinFull, type ITwinGridProps, DataStatus, @@ -267,6 +269,92 @@ export const WithPostProcessCallback: StoryObj = { }, }; +const describeQuery = (query: ITwinDataQuery) => + `${query.requestType || "all"}${ + query.filterText ? ` "${query.filterText}"` : "" + }`; + +const WithOnDataStateChangeRender = ({ + onDataStateChange: reportToActionsPanel, + ...args +}: ITwinGridProps) => { + const [log, setLog] = React.useState([]); + const fetchStartedAt = React.useRef>({}); + + const onDataStateChange = React.useCallback( + (state: ITwinDataState) => { + reportToActionsPanel?.(state); + + const query = describeQuery(state.query); + const append = (line: string) => + setLog((log) => [`${query}: ${line}`, ...log].slice(0, 12)); + + if (state.status === DataStatus.Fetching) { + fetchStartedAt.current[query] = performance.now(); + append("fetching"); + return; + } + + const startedAt = fetchStartedAt.current[query]; + const timing = + startedAt === undefined + ? "from the iTwins already loaded" + : `after ${Math.round(performance.now() - startedAt)}ms`; + append( + [ + `${state.status} ${timing}`, + `${state.iTwins.length} iTwins`, + state.hasMore ? "more pages remain" : undefined, + state.error ? String(state.error) : undefined, + ] + .filter(Boolean) + .join(", ") + ); + }, + [reportToActionsPanel] + ); + + return ( +
+ + Property onDataStateChange reports the grid's data + state as it changes, newest first, and sends each report to the Actions + panel. Search to see a query reported before its result. The favorites + and recents tabs answer a search from the iTwins they already hold, so + they report a result with no fetch before it. + + + {log.length === 0 ? ( + Nothing reported yet. + ) : ( + log.map((line, index) => ( + + {line} + + )) + )} + + +
+ ); +}; + +export const WithOnDataStateChange: StoryObj = { + render: (args) => , + args: { + apiOverrides: { serverEnvironmentPrefix: "qa" }, + }, +}; + export const FetchAllSubclasses: StoryObj = { args: { apiOverrides: { serverEnvironmentPrefix: "qa" }, From 6d22ce79586a4bf9e0e37ccc2f1d614adaed5947 Mon Sep 17 00:00:00 2001 From: Lukasz Kokot Date: Tue, 18 Aug 2026 14:41:49 -0400 Subject: [PATCH 05/11] Extract the iTwin page request into createFetchITwinsFn Follows createFetchIModelsFn in useIModelData, which already owns URL assembly and the fetch for the iModel grid. Takes an options object rather than that one's ten positional parameters, and reads the query descriptor directly, since the request type, filter text, subclass and ordering are exactly what the URL needs. The effect keeps everything stateful: the superseded-request guard, the totalCount and pagination writes, the favorites reset, and the abort on cleanup. A totalCount of undefined now means the response carried no count. Returning Number(null) instead would have reported zero. --- .../src/containers/ITwinGrid/useITwinData.ts | 141 ++++++++++++------ 1 file changed, 92 insertions(+), 49 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts index cfb36cbf..05d67a9b 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts @@ -216,54 +216,21 @@ export const useITwinData = ({ } const requestId = Symbol(); activeRequestRef.current = requestId; - const abortController = new AbortController(); - const endpoint = isClientSideFiltered(requestType) ? requestType : ""; - const resolvedITwinSubClass = iTwinSubClass === "All" ? "" : iTwinSubClass; - const subClass = `?subClass=${resolvedITwinSubClass}`; - const paging = `&$skip=${page * PAGE_SIZE}&$top=${PAGE_SIZE}`; - const search = - isClientSideFiltered(requestType) || !filterOptions - ? "" - : `&$search=${encodeURIComponent(String(filterOptions).trim())}`; - const orderby = - isClientSideFiltered(requestType) || !orderbyOptions - ? "" - : `&$orderby=${encodeURIComponent(String(orderbyOptions).trim())}`; - - const url = `${_getAPIServer( - serverEnvironmentPrefix - )}/itwins/${endpoint}${subClass}${paging}${search}${orderby}`; - - const makeFetchRequest = async () => { - const options: RequestInit = { - signal: abortController.signal, - headers: { - "Cache-Control": - requestType === "favorites" && shouldRefetchFavorites - ? "no-cache" - : "", - Authorization: - typeof accessToken === "function" - ? await accessToken() - : accessToken, - Accept: "application/vnd.bentley.itwin-platform.v1+json", - Prefer: "return=representation", - "x-total-count": "true", - }, - }; + const { abortController, fetchITwins } = createFetchITwinsFn({ + query, + accessToken, + page, + serverEnvironmentPrefix, + shouldRefetchFavorites, + }); - const response = await fetch(url, options); - const result: { iTwins: ITwinFull[] } = response.ok - ? await response.json() - : await response.text().then((errorText) => { - throw new Error(errorText); - }); + const applyResult = async () => { + const result = await fetchITwins(); if (activeRequestRef.current !== requestId) { return; } - const totalCountHeader = response.headers.get("x-total-count"); - if (totalCountHeader !== null) { - setTotalCount(Number(totalCountHeader)); + if (result.totalCount !== undefined) { + setTotalCount(result.totalCount); } fetchingMoreRef.current = false; requestType === "favorites" && resetShouldRefetchFavorites?.(); @@ -272,12 +239,12 @@ export const useITwinData = ({ status: DataStatus.Complete, iTwins: page === 0 ? result.iTwins : [...state.iTwins, ...result.iTwins], - hasMore: result.iTwins.length === PAGE_SIZE, + hasMore: result.hasMore, error: undefined, })); }; - makeFetchRequest().catch((e) => { + applyResult().catch((e) => { if (activeRequestRef.current !== requestId || e.name === "AbortError") { // Superseded or aborted, not a failure worth reporting. return; @@ -301,13 +268,11 @@ export const useITwinData = ({ requestType, data, serverEnvironmentPrefix, - filterOptions, - orderbyOptions, + query, page, fetchState.hasMore, refetchCount, retryCount, - iTwinSubClass, shouldRefetchFavorites, resetShouldRefetchFavorites, logger, @@ -320,3 +285,81 @@ export const useITwinData = ({ refetchITwins, }; }; + +/** + * Builds the request for one page of iTwins. Resolves with the page, or throws what the API + * answered. A totalCount of undefined means the response carried no count, which is not zero. + */ +const createFetchITwinsFn = ({ + query, + accessToken, + page, + serverEnvironmentPrefix, + shouldRefetchFavorites, +}: { + query: ITwinDataQuery; + accessToken: AccessTokenProvider; + page: number; + serverEnvironmentPrefix?: "" | "dev" | "qa"; + shouldRefetchFavorites?: boolean; +}): { + abortController: AbortController; + fetchITwins: () => Promise<{ + iTwins: ITwinFull[]; + totalCount: number | undefined; + hasMore: boolean; + }>; +} => { + const { requestType, filterText, iTwinSubClass, orderby } = query; + const clientSideFiltered = isClientSideFiltered(requestType); + const endpoint = clientSideFiltered ? requestType : ""; + const subClass = `?subClass=${iTwinSubClass === "All" ? "" : iTwinSubClass}`; + const paging = `&$skip=${page * PAGE_SIZE}&$top=${PAGE_SIZE}`; + const search = + clientSideFiltered || !filterText + ? "" + : `&$search=${encodeURIComponent(filterText.trim())}`; + const ordering = + clientSideFiltered || !orderby + ? "" + : `&$orderby=${encodeURIComponent(orderby.trim())}`; + + const abortController = new AbortController(); + const url = `${_getAPIServer( + serverEnvironmentPrefix + )}/itwins/${endpoint}${subClass}${paging}${search}${ordering}`; + + const doFetchRequest = async () => { + const options: RequestInit = { + signal: abortController.signal, + headers: { + "Cache-Control": + requestType === "favorites" && shouldRefetchFavorites + ? "no-cache" + : "", + Authorization: + typeof accessToken === "function" ? await accessToken() : accessToken, + Accept: "application/vnd.bentley.itwin-platform.v1+json", + Prefer: "return=representation", + "x-total-count": "true", + }, + }; + + const response = await fetch(url, options); + const result: { iTwins: ITwinFull[] } = response.ok + ? await response.json() + : await response.text().then((errorText) => { + throw new Error(errorText); + }); + + const totalCountHeader = response.headers.get("x-total-count"); + return { + iTwins: result.iTwins, + totalCount: + totalCountHeader !== null ? Number(totalCountHeader) : undefined, + hasMore: result.iTwins.length === PAGE_SIZE, + }; + }; + + return { abortController, fetchITwins: doFetchRequest }; +}; From eadb8c968bc47f27b869d09a10ebdfbbc1fcc740 Mon Sep 17 00:00:00 2001 From: Lukasz Kokot Date: Tue, 18 Aug 2026 14:43:09 -0400 Subject: [PATCH 06/11] Name the iTwin fetch state's transitions The state became one object so that a render could never show one query's status beside another query's iTwins, but it was still written from seven scattered setFetchState calls, four of them inside the fetch effect. The invariant held by convention. Each write is now a named transition and setFetchState is referenced nowhere else. Every transition takes what it needs as an argument so it can carry empty dependencies and sit in the effect dependency arrays without re-running them. Three things stay with the caller on purpose: the logging, since a transition that logs would need the logger in its dependencies; the totalCount, pagination and favorites writes, so their order relative to the state write is unchanged; and the page-zero check ahead of markFetching, which is pagination rather than state. --- .../src/containers/ITwinGrid/useITwinData.ts | 122 ++++++++++++------ 1 file changed, 84 insertions(+), 38 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts index 05d67a9b..cc5f8be8 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts @@ -113,17 +113,84 @@ export const useITwinData = ({ } }, [dataState, query]); - const resetData = React.useCallback(() => { + /** Start over for the query in hand. Reads it from the ref, never a closure: see resetData. */ + const reset = React.useCallback(() => { setFetchState((state) => hasStartedOver(state, queryRef.current) ? state : startingOver(queryRef.current) ); + }, []); + + /** The iTwins in hand already answer the new query, so keep them and just retarget. */ + const applyQuery = React.useCallback((query: ITwinDataQuery) => { + setFetchState((state) => + sameQuery(state.query, query) ? state : { ...state, query } + ); + }, []); + + const markFetching = React.useCallback(() => { + setFetchState((state) => + state.status === DataStatus.Fetching + ? state + : { ...state, status: DataStatus.Fetching, error: undefined } + ); + }, []); + + const pageLoaded = React.useCallback( + (page: { iTwins: ITwinFull[]; isFirstPage: boolean; hasMore: boolean }) => { + setFetchState((state) => ({ + ...state, + status: DataStatus.Complete, + iTwins: page.isFirstPage + ? page.iTwins + : [...state.iTwins, ...page.iTwins], + hasMore: page.hasMore, + error: undefined, + })); + }, + [] + ); + + const pageFailed = React.useCallback( + (failure: { error: unknown; isFirstPage: boolean }) => { + setFetchState((state) => ({ + ...state, + status: DataStatus.FetchFailed, + iTwins: failure.isFirstPage ? [] : state.iTwins, + error: failure.error, + })); + }, + [] + ); + + /** Leaves hasMore alone: flipping it here would re-run the fetch effect into this same branch. */ + const tokenRequired = React.useCallback(() => { + setFetchState((state) => ({ + ...state, + status: DataStatus.TokenRequired, + iTwins: [], + error: undefined, + })); + }, []); + + const dataProvided = React.useCallback((iTwins: ITwinFull[]) => { + setFetchState((state) => ({ + ...state, + status: DataStatus.Complete, + iTwins, + hasMore: false, + error: undefined, + })); + }, []); + + const resetData = React.useCallback(() => { + reset(); setTotalCount(undefined); setPage(0); fetchingMoreRef.current = true; lastPageFailedRef.current = false; - }, []); + }, [reset]); // We start in a fetching state const fetchingMoreRef = React.useRef(true); @@ -164,12 +231,9 @@ export const useITwinData = ({ if (morePagesRef.current || !isClientSideFiltered(requestType)) { resetData(); } else { - // The data already in hand answers the new query, but it is a new query all the same. - setFetchState((state) => - sameQuery(state.query, query) ? state : { ...state, query } - ); + applyQuery(query); } - }, [query, requestType, resetData]); + }, [query, requestType, resetData, applyQuery]); React.useEffect(() => { // If any of the dependencies change, always restart the fetch from scratch. @@ -189,30 +253,15 @@ export const useITwinData = ({ return; } if (data) { - setFetchState((state) => ({ - ...state, - status: DataStatus.Complete, - iTwins: data, - hasMore: false, - error: undefined, - })); + dataProvided(data); return; } if (!accessToken) { - setFetchState((state) => ({ - ...state, - status: DataStatus.TokenRequired, - iTwins: [], - error: undefined, - })); + tokenRequired(); return; } if (page === 0) { - setFetchState((state) => - state.status === DataStatus.Fetching - ? state - : { ...state, status: DataStatus.Fetching, error: undefined } - ); + markFetching(); } const requestId = Symbol(); activeRequestRef.current = requestId; @@ -234,14 +283,11 @@ export const useITwinData = ({ } fetchingMoreRef.current = false; requestType === "favorites" && resetShouldRefetchFavorites?.(); - setFetchState((state) => ({ - ...state, - status: DataStatus.Complete, - iTwins: - page === 0 ? result.iTwins : [...state.iTwins, ...result.iTwins], + pageLoaded({ + iTwins: result.iTwins, + isFirstPage: page === 0, hasMore: result.hasMore, - error: undefined, - })); + }); }; applyResult().catch((e) => { @@ -251,12 +297,7 @@ export const useITwinData = ({ } fetchingMoreRef.current = false; lastPageFailedRef.current = true; - setFetchState((state) => ({ - ...state, - status: DataStatus.FetchFailed, - iTwins: page === 0 ? [] : state.iTwins, - error: e, - })); + pageFailed({ error: e, isFirstPage: page === 0 }); logger.logError("Failed to fetch iTwins", e); }); return () => { @@ -276,6 +317,11 @@ export const useITwinData = ({ shouldRefetchFavorites, resetShouldRefetchFavorites, logger, + dataProvided, + tokenRequired, + markFetching, + pageLoaded, + pageFailed, ]); return { iTwins: filteredProjects, From 6c6bb3c6a073dbe89044d1554c610f7e36f04ca8 Mon Sep 17 00:00:00 2001 From: Lukasz Kokot Date: Tue, 18 Aug 2026 14:45:46 -0400 Subject: [PATCH 07/11] Move the iTwin fetch state into useITwinDataState useITwinData now derives the query, decides what to request and when, and drives the transitions. What the grid's data is, and what a report says about it, lives next door. Client side filtering moves with it, because the filtered list is what a report carries rather than something the fetch needs. The hook takes only the query: filterText is filterOptions ?? "", and useITwinFilter lowercases both to the same empty string. The ref that reset reads is now declared inside the hook, which keeps it ahead of the two reset effects that call reset, since a hook's effects are queued where the hook is called. --- .../src/containers/ITwinGrid/useITwinData.ts | 153 +++-------------- .../containers/ITwinGrid/useITwinDataState.ts | 161 ++++++++++++++++++ 2 files changed, 182 insertions(+), 132 deletions(-) create mode 100644 packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts index cc5f8be8..8439c01c 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.ts @@ -8,7 +8,6 @@ import { useLogger } from "../../contexts/LoggerContext"; import { AccessTokenProvider, ApiOverrides, - DataStatus, ITwinDataQuery, ITwinDataState, ITwinFilterOptions, @@ -16,7 +15,7 @@ import { ITwinSubClass, } from "../../types"; import { _getAPIServer } from "../../utils/_apiOverrides"; -import { useITwinFilter } from "./useITwinFilter"; +import { useITwinDataState } from "./useITwinDataState"; export interface ProjectDataHookOptions { requestType?: "favorites" | "recents" | ""; @@ -32,37 +31,9 @@ export interface ProjectDataHookOptions { const PAGE_SIZE = 100; -interface FetchState { - query: ITwinDataQuery; - status: DataStatus; - iTwins: ITwinFull[]; - hasMore: boolean; - error?: unknown; -} - const isClientSideFiltered = (requestType: string) => ["favorites", "recents"].includes(requestType); -const startingOver = (query: ITwinDataQuery): FetchState => ({ - query, - status: DataStatus.Fetching, - iTwins: [], - hasMore: true, - error: undefined, -}); - -const sameQuery = (a: ITwinDataQuery, b: ITwinDataQuery) => - a.requestType === b.requestType && - a.filterText === b.filterText && - a.iTwinSubClass === b.iTwinSubClass && - a.orderby === b.orderby; - -const hasStartedOver = (state: FetchState, query: ITwinDataQuery) => - sameQuery(state.query, query) && - state.status === DataStatus.Fetching && - state.iTwins.length === 0 && - state.hasMore; - export const useITwinData = ({ requestType = "", iTwinSubClass = "Project", @@ -89,100 +60,18 @@ export const useITwinData = ({ }), [requestType, filterOptions, iTwinSubClass, orderbyOptions] ); - const [fetchState, setFetchState] = React.useState(() => - startingOver(query) - ); - const filteredProjects = useITwinFilter(fetchState.iTwins, filterOptions); - - const queryRef = React.useRef(query); - const onDataStateChangeRef = React.useRef(onDataStateChange); - React.useEffect(() => { - queryRef.current = query; - onDataStateChangeRef.current = onDataStateChange; - }); - - const dataState = React.useMemo( - () => ({ ...fetchState, iTwins: filteredProjects }), - [fetchState, filteredProjects] - ); - React.useEffect(() => { - // The new query has not reached the state below yet, so reporting now would pair it with the - // previous query's result. - if (sameQuery(dataState.query, query)) { - onDataStateChangeRef.current?.(dataState); - } - }, [dataState, query]); - - /** Start over for the query in hand. Reads it from the ref, never a closure: see resetData. */ - const reset = React.useCallback(() => { - setFetchState((state) => - hasStartedOver(state, queryRef.current) - ? state - : startingOver(queryRef.current) - ); - }, []); - - /** The iTwins in hand already answer the new query, so keep them and just retarget. */ - const applyQuery = React.useCallback((query: ITwinDataQuery) => { - setFetchState((state) => - sameQuery(state.query, query) ? state : { ...state, query } - ); - }, []); - - const markFetching = React.useCallback(() => { - setFetchState((state) => - state.status === DataStatus.Fetching - ? state - : { ...state, status: DataStatus.Fetching, error: undefined } - ); - }, []); - - const pageLoaded = React.useCallback( - (page: { iTwins: ITwinFull[]; isFirstPage: boolean; hasMore: boolean }) => { - setFetchState((state) => ({ - ...state, - status: DataStatus.Complete, - iTwins: page.isFirstPage - ? page.iTwins - : [...state.iTwins, ...page.iTwins], - hasMore: page.hasMore, - error: undefined, - })); - }, - [] - ); - - const pageFailed = React.useCallback( - (failure: { error: unknown; isFirstPage: boolean }) => { - setFetchState((state) => ({ - ...state, - status: DataStatus.FetchFailed, - iTwins: failure.isFirstPage ? [] : state.iTwins, - error: failure.error, - })); - }, - [] - ); - - /** Leaves hasMore alone: flipping it here would re-run the fetch effect into this same branch. */ - const tokenRequired = React.useCallback(() => { - setFetchState((state) => ({ - ...state, - status: DataStatus.TokenRequired, - iTwins: [], - error: undefined, - })); - }, []); - - const dataProvided = React.useCallback((iTwins: ITwinFull[]) => { - setFetchState((state) => ({ - ...state, - status: DataStatus.Complete, - iTwins, - hasMore: false, - error: undefined, - })); - }, []); + const { + status, + iTwins, + hasMore, + reset, + applyQuery, + markFetching, + pageLoaded, + pageFailed, + tokenRequired, + dataProvided, + } = useITwinDataState(query, onDataStateChange); const resetData = React.useCallback(() => { reset(); @@ -219,10 +108,10 @@ export const useITwinData = ({ const activeRequestRef = React.useRef(undefined); - const morePagesRef = React.useRef(fetchState.hasMore); + const morePagesRef = React.useRef(hasMore); React.useEffect(() => { - morePagesRef.current = fetchState.hasMore; - }, [fetchState.hasMore]); + morePagesRef.current = hasMore; + }, [hasMore]); React.useEffect(() => { // If filter changes but we already have all the data for favorites or recents, @@ -249,7 +138,7 @@ export const useITwinData = ({ ]); React.useEffect(() => { - if (!fetchState.hasMore) { + if (!hasMore) { return; } if (data) { @@ -311,7 +200,7 @@ export const useITwinData = ({ serverEnvironmentPrefix, query, page, - fetchState.hasMore, + hasMore, refetchCount, retryCount, shouldRefetchFavorites, @@ -324,10 +213,10 @@ export const useITwinData = ({ pageFailed, ]); return { - iTwins: filteredProjects, - status: fetchState.status, + iTwins, + status, totalCount, - fetchMore: fetchState.hasMore ? fetchMore : undefined, + fetchMore: hasMore ? fetchMore : undefined, refetchITwins, }; }; diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts new file mode 100644 index 00000000..e651706f --- /dev/null +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Bentley Systems, Incorporated. All rights reserved. + * See LICENSE.md in the project root for license terms and full copyright notice. + *--------------------------------------------------------------------------------------------*/ +import React from "react"; + +import { + DataStatus, + ITwinDataQuery, + ITwinDataState, + ITwinFull, +} from "../../types"; +import { useITwinFilter } from "./useITwinFilter"; + +/** Held together so a render can never show one query's status beside another query's iTwins. */ +interface FetchState { + query: ITwinDataQuery; + status: DataStatus; + iTwins: ITwinFull[]; + hasMore: boolean; + error?: unknown; +} + +const startingOver = (query: ITwinDataQuery): FetchState => ({ + query, + status: DataStatus.Fetching, + iTwins: [], + hasMore: true, + error: undefined, +}); + +const sameQuery = (a: ITwinDataQuery, b: ITwinDataQuery) => + a.requestType === b.requestType && + a.filterText === b.filterText && + a.iTwinSubClass === b.iTwinSubClass && + a.orderby === b.orderby; + +const hasStartedOver = (state: FetchState, query: ITwinDataQuery) => + sameQuery(state.query, query) && + state.status === DataStatus.Fetching && + state.iTwins.length === 0 && + state.hasMore; + +/** + * The iTwin grid's data as a consumer sees it, and the transitions that move it along. Client side + * filtering lives here because it is part of what a report says, not part of deciding what to fetch. + */ +export const useITwinDataState = ( + query: ITwinDataQuery, + onDataStateChange?: (state: ITwinDataState) => void +) => { + const [fetchState, setFetchState] = React.useState(() => + startingOver(query) + ); + const iTwins = useITwinFilter(fetchState.iTwins, query.filterText); + + const queryRef = React.useRef(query); + const onDataStateChangeRef = React.useRef(onDataStateChange); + React.useEffect(() => { + queryRef.current = query; + onDataStateChangeRef.current = onDataStateChange; + }); + + const dataState = React.useMemo( + () => ({ ...fetchState, iTwins }), + [fetchState, iTwins] + ); + React.useEffect(() => { + // The new query has not reached the state yet, so reporting now would pair it with the + // previous query's result. + if (sameQuery(dataState.query, query)) { + onDataStateChangeRef.current?.(dataState); + } + }, [dataState, query]); + + /** Start over for the query in hand. Reads it from the ref so the caller can reset from an + * effect that must not depend on the query. */ + const reset = React.useCallback(() => { + setFetchState((state) => + hasStartedOver(state, queryRef.current) + ? state + : startingOver(queryRef.current) + ); + }, []); + + /** The iTwins in hand already answer the new query, so keep them and just retarget. */ + const applyQuery = React.useCallback((query: ITwinDataQuery) => { + setFetchState((state) => + sameQuery(state.query, query) ? state : { ...state, query } + ); + }, []); + + const markFetching = React.useCallback(() => { + setFetchState((state) => + state.status === DataStatus.Fetching + ? state + : { ...state, status: DataStatus.Fetching, error: undefined } + ); + }, []); + + const pageLoaded = React.useCallback( + (page: { iTwins: ITwinFull[]; isFirstPage: boolean; hasMore: boolean }) => { + setFetchState((state) => ({ + ...state, + status: DataStatus.Complete, + iTwins: page.isFirstPage + ? page.iTwins + : [...state.iTwins, ...page.iTwins], + hasMore: page.hasMore, + error: undefined, + })); + }, + [] + ); + + const pageFailed = React.useCallback( + (failure: { error: unknown; isFirstPage: boolean }) => { + setFetchState((state) => ({ + ...state, + status: DataStatus.FetchFailed, + iTwins: failure.isFirstPage ? [] : state.iTwins, + error: failure.error, + })); + }, + [] + ); + + /** Leaves hasMore alone: flipping it here would re-run the caller's fetch effect into this + * same branch. */ + const tokenRequired = React.useCallback(() => { + setFetchState((state) => ({ + ...state, + status: DataStatus.TokenRequired, + iTwins: [], + error: undefined, + })); + }, []); + + const dataProvided = React.useCallback((iTwins: ITwinFull[]) => { + setFetchState((state) => ({ + ...state, + status: DataStatus.Complete, + iTwins, + hasMore: false, + error: undefined, + })); + }, []); + + return { + status: fetchState.status, + iTwins, + hasMore: fetchState.hasMore, + reset, + applyQuery, + markFetching, + pageLoaded, + pageFailed, + tokenRequired, + dataProvided, + }; +}; From cfeea7390b9ffb7487c56fe09f8f2e023d0904fc Mon Sep 17 00:00:00 2001 From: Lukasz Kokot Date: Tue, 18 Aug 2026 14:52:01 -0400 Subject: [PATCH 08/11] Use nullish coalescing for the subclass test's fallback searchParams.get returns null rather than an empty string, so ?? says what is meant and clears the lint warning. --- .../src/containers/ITwinGrid/useITwinData.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4802b372..8876e278 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinData.test.ts @@ -925,7 +925,7 @@ describe("useITwinData hook", () => { res( ctx.status(200), ctx.json({ - iTwins: [{ id: req.url.searchParams.get("subClass") || "none" }], + iTwins: [{ id: req.url.searchParams.get("subClass") ?? "none" }], }) ) ) From 3096f0088be2cfb41ef622af28f53d7a2a6e89f8 Mon Sep 17 00:00:00 2001 From: Lukasz Kokot Date: Tue, 18 Aug 2026 14:53:34 -0400 Subject: [PATCH 09/11] Derive the iTwin fetch state from the reported one The two declared the same five fields, so a field added to the report had to be added twice. FetchState now takes them from ITwinDataState. They stay separate types because the iTwins differ: the state holds every page fetched, a report carries only what client side filtering kept. Collapsing them would leave the variable name as the only thing saying which is which, and reporting the state directly would then typecheck. --- .../src/containers/ITwinGrid/useITwinDataState.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts index e651706f..573ba25f 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts @@ -12,13 +12,10 @@ import { } from "../../types"; import { useITwinFilter } from "./useITwinFilter"; -/** Held together so a render can never show one query's status beside another query's iTwins. */ -interface FetchState { - query: ITwinDataQuery; - status: DataStatus; +/** One object, so a render can never pair one query's status with another query's iTwins. */ +interface FetchState extends Omit { + /** Every page fetched, where a report carries only what client side filtering kept. */ iTwins: ITwinFull[]; - hasMore: boolean; - error?: unknown; } const startingOver = (query: ITwinDataQuery): FetchState => ({ From fa25770559b0bd7716c3903b04467098e5eec2c5 Mon Sep 17 00:00:00 2001 From: Lukasz Kokot Date: Tue, 18 Aug 2026 17:35:18 -0400 Subject: [PATCH 10/11] Shorten the onDataStateChange change file entry The payload belongs in the prop's own documentation, not the changelog. --- .../lk-itwin-grid-data-state_2026-08-17-00-00.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/changes/@itwin/imodel-browser-react/lk-itwin-grid-data-state_2026-08-17-00-00.json b/common/changes/@itwin/imodel-browser-react/lk-itwin-grid-data-state_2026-08-17-00-00.json index 6abf99af..c574d20b 100644 --- a/common/changes/@itwin/imodel-browser-react/lk-itwin-grid-data-state_2026-08-17-00-00.json +++ b/common/changes/@itwin/imodel-browser-react/lk-itwin-grid-data-state_2026-08-17-00-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@itwin/imodel-browser-react", - "comment": "Add `onDataStateChange` to `ITwinGrid`, reporting the query, status, iTwins, `hasMore` and any error each time the grid's data state changes", + "comment": "Add onDataStateChange callback to ITwinGrid", "type": "minor" }, { From 6e8d6fb8df7ae8522b3e49e913c132e0b58750a5 Mon Sep 17 00:00:00 2001 From: Lukasz Kokot Date: Tue, 18 Aug 2026 17:40:56 -0400 Subject: [PATCH 11/11] Drop the useITwinDataState doc comment It argued for where the code sits rather than saying anything the signature does not. --- .../src/containers/ITwinGrid/useITwinDataState.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts index 573ba25f..f7eebae7 100644 --- a/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts +++ b/packages/modules/imodel-browser/src/containers/ITwinGrid/useITwinDataState.ts @@ -38,10 +38,6 @@ const hasStartedOver = (state: FetchState, query: ITwinDataQuery) => state.iTwins.length === 0 && state.hasMore; -/** - * The iTwin grid's data as a consumer sees it, and the transitions that move it along. Client side - * filtering lives here because it is part of what a report says, not part of deciding what to fetch. - */ export const useITwinDataState = ( query: ITwinDataQuery, onDataStateChange?: (state: ITwinDataState) => void