diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8144f4fe..047cff5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - node-version: [20.x] + node-version: [24.x] steps: - uses: actions/checkout@v3 diff --git a/common/changes/@itwin/imodel-browser-react/hl662-fix-abort-controller-strict-mode_2026-06-26-14-27.json b/common/changes/@itwin/imodel-browser-react/hl662-fix-abort-controller-strict-mode_2026-06-26-14-27.json new file mode 100644 index 00000000..7a1af92e --- /dev/null +++ b/common/changes/@itwin/imodel-browser-react/hl662-fix-abort-controller-strict-mode_2026-06-26-14-27.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@itwin/imodel-browser-react", + "comment": "Fix AbortController aborting in-flight fetch in React 18 Strict Mode", + "type": "patch" + } + ], + "packageName": "@itwin/imodel-browser-react" +} diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.test.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.test.ts index 4adc2890..6c09f280 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.test.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { renderHook } from "@testing-library/react-hooks"; import { rest } from "msw"; -import { act } from "react"; +import { act, StrictMode } from "react"; import { server } from "../../tests/mocks/server"; import { DataStatus } from "../../types"; @@ -15,7 +15,10 @@ describe("useIModelData hook", () => { beforeAll(() => server.listen()); // Reset any request handlers that we may add during the tests, // so they don't affect other tests. - afterEach(() => server.resetHandlers()); + afterEach(() => { + server.resetHandlers(); + jest.restoreAllMocks(); + }); // Clean up after the tests are finished. afterAll(() => { server.close(); @@ -311,6 +314,24 @@ describe("useIModelData hook", () => { expect(watcher).toHaveBeenCalledTimes(2); }); + it("completes fetch when mounted inside React.StrictMode", async () => { + const { result, waitForValueToChange } = renderHook( + () => useIModelData({ iTwinId: "iTwinId", accessToken: "accessToken" }), + { wrapper: StrictMode } + ); + + await waitForValueToChange( + () => result.current.status === DataStatus.Complete, + { timeout: 3000 } + ); + + expect(result.current.status).toEqual(DataStatus.Complete); + expect(result.current.iModels).toContainEqual({ + id: "fakeId", + displayName: "fakeName", + }); + }); + it.each([ { missing: "accessToken", diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts index b0ca0d2d..d320c47b 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts @@ -2,7 +2,13 @@ * Copyright (c) Bentley Systems, Incorporated. All rights reserved. * See LICENSE.md in the project root for license terms and full copyright notice. *--------------------------------------------------------------------------------------------*/ -import React, { useEffect } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { AccessTokenProvider, @@ -48,20 +54,21 @@ export const useIModelData = ({ onLoadMore, onRefetch, }: IModelDataHookOptions) => { - const [needsUpdate, setNeedsUpdate] = React.useState(true); - const [iModels, setIModels] = React.useState([]); - const [status, setStatus] = React.useState(); - const [page, setPage] = React.useState(0); - const [morePagesAvailable, setMorePagesAvailable] = React.useState(true); - const [abortController, setAbortController] = React.useState< - AbortController | undefined - >(undefined); + const [needsUpdate, setNeedsUpdate] = useState(true); + const [iModels, setIModels] = useState([]); + const [status, setStatus] = useState(); + const [page, setPage] = useState(0); + const [morePagesAvailable, setMorePagesAvailable] = useState(true); + const abortControllerRef = useRef( + undefined + ); + const activeRequestRef = useRef(undefined); const sortType = sortOptions && ["name", "createdDateTime"].includes(sortOptions.sortType) ? sortOptions.sortType : undefined; //Only available sort-by API at the moment. - const [previousSortOptions, setPreviousSortOptions] = React.useState< + const [previousSortOptions, setPreviousSortOptions] = useState< IModelSortOptions | undefined >(sortOptions && { ...sortOptions }); const sortDescending = sortOptions?.descending; @@ -72,7 +79,7 @@ export const useIModelData = ({ ); // For recents and favorites, apply client-side filtering based on searchText - const filteredIModels = React.useMemo(() => { + const filteredIModels = useMemo(() => { if ( !searchText?.trim() || (requestType !== "recents" && requestType !== "favorites") @@ -95,10 +102,7 @@ export const useIModelData = ({ setPreviousSortOptions(sortOptions); } - // cleanup the abort controller when unmounting - useEffect(() => () => abortController?.abort(), [abortController]); - - const reset = React.useCallback(() => { + const reset = useCallback(() => { if (dataMode === "external") { return; } @@ -110,7 +114,7 @@ export const useIModelData = ({ setNeedsUpdate(true); }, [dataMode]); - const fetchMore = React.useCallback(() => { + const fetchMore = useCallback(() => { if (dataMode === "external") { return; } @@ -128,7 +132,7 @@ export const useIModelData = ({ setNeedsUpdate(true); }, [dataMode, needsUpdate, status, morePagesAvailable]); - React.useEffect(() => { + useEffect(() => { if (dataMode === "external") { return; } @@ -152,7 +156,7 @@ export const useIModelData = ({ reset, ]); - React.useEffect(() => { + useEffect(() => { if (dataMode === "external") { return; } @@ -176,14 +180,14 @@ export const useIModelData = ({ ]); // Main function - React.useEffect(() => { + useEffect(() => { if (dataMode === "external" || !needsUpdate) { return; } setNeedsUpdate(false); - abortController?.abort(); - setAbortController(undefined); + abortControllerRef.current?.abort(); + abortControllerRef.current = undefined; if (!accessToken || !iTwinId) { setStatus( @@ -202,6 +206,9 @@ export const useIModelData = ({ // Otherwise, fetch from server setStatus(DataStatus.Fetching); + const requestId = Symbol(); + activeRequestRef.current = requestId; + const { abortController: newAbortController, fetchIModels } = createFetchIModelsFn( iTwinId, @@ -215,10 +222,11 @@ export const useIModelData = ({ apiOverrides?.serverEnvironmentPrefix, requestType ); - setAbortController(newAbortController); + abortControllerRef.current = newAbortController; fetchIModels() .then(({ iModels, morePagesAvailable }) => { + if (activeRequestRef.current !== requestId) return; setMorePagesAvailable(morePagesAvailable); setIModels((prev) => page === 0 ? [...iModels] : [...prev, ...iModels] @@ -226,10 +234,8 @@ export const useIModelData = ({ setStatus(DataStatus.Complete); }) .catch((e) => { - if (e.name === "AbortError") { - // Aborting because unmounting is not an error, swallow. + if (activeRequestRef.current !== requestId || e.name === "AbortError") return; - } setIModels([]); setMorePagesAvailable(false); setStatus(DataStatus.FetchFailed); @@ -237,15 +243,12 @@ export const useIModelData = ({ }); }, [ dataMode, - abortController, accessToken, apiOverrides?.data, apiOverrides?.serverEnvironmentPrefix, - iModels, iTwinId, maxCount, pageSize, - morePagesAvailable, needsUpdate, page, requestType, @@ -255,6 +258,15 @@ export const useIModelData = ({ sortType, ]); + // Abort any in-flight request and invalidate stale results on unmount + useEffect( + () => () => { + activeRequestRef.current = undefined; + abortControllerRef.current?.abort(); + }, + [] + ); + if (dataMode === "external") { return { iModels: apiOverrides?.data ?? [], diff --git a/rush.json b/rush.json index 645a1eac..25f8911f 100644 --- a/rush.json +++ b/rush.json @@ -1,8 +1,8 @@ { "$schema": "https://developer.microsoft.com/json-schemas/rush/v5/rush.schema.json", "rushVersion": "5.112.2", - "pnpmVersion": "7.32.2", - "nodeSupportedVersionRange": "^18.12.0 || ^20.9.0 || ^22.11.0", + "pnpmVersion": "7.33.7", + "nodeSupportedVersionRange": "^18.12.0 || ^20.9.0 || ^22.11.0 || ^24.11.0", "projectFolderMinDepth": 2, "projectFolderMaxDepth": 3, "repository": {