From a4ee4e5a5dccc04501e599851ae1d17159636820 Mon Sep 17 00:00:00 2001 From: Nam Le <50554904+hl662@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:31:12 -0400 Subject: [PATCH 1/7] fix(imodel-browser): prevent AbortController from breaking fetch in React 18 Strict Mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React 18 Strict Mode double-invokes effects (mount → cleanup → mount). The previous implementation stored AbortController in useState, with a separate cleanup-only effect that called abort(). On Strict Mode's simulated unmount, the abort fired and needsUpdate=false prevented a re-fetch, leaving the hook permanently stuck at status=Fetching. Fix: move AbortController to useRef (abortControllerRef) and add a Symbol-based active request tracker (activeRequestRef). Each fetch run assigns a unique Symbol as the active request ID. The .then()/.catch() callbacks check this ID before applying results, so stale fetches from aborted effect runs are silently dropped. A separate unmount-only effect ([] deps) clears activeRequestRef and aborts any in-flight request on true unmount. Also: - Add --forceExit to jest to prevent the process hanging due to MSW keeping the Node event loop alive after tests complete. - Add pnpm-lock.yaml to .gitignore to prevent per-package lockfiles (created by the pnpm install workaround for the rush install pnpm 7.32.2 bug) from polluting diffs. - Add a regression test: 'completes fetch when mounted inside React.StrictMode' placed before tests that spy on window.fetch to avoid test isolation issues. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 ++ packages/modules/imodel-browser/package.json | 2 +- .../iModelGrid/useIModelData.test.ts | 25 +++++++++++- .../containers/iModelGrid/useIModelData.ts | 38 +++++++++++-------- 4 files changed, 49 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index ebf38920..5f5af904 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ yarn-error.log* # Dependency directories node_modules/ +# Per-package lockfiles (Rush manages a single lockfile at common/config/rush/pnpm-lock.yaml) +pnpm-lock.yaml + # Optional npm cache directory .npm diff --git a/packages/modules/imodel-browser/package.json b/packages/modules/imodel-browser/package.json index ca3f1c9d..1fe4b663 100644 --- a/packages/modules/imodel-browser/package.json +++ b/packages/modules/imodel-browser/package.json @@ -33,7 +33,7 @@ "scripts": { "start": "rollup -cw", "build": "rollup --silent -c", - "test": "jest --silent", + "test": "jest --silent --forceExit", "clean": "rimraf cjs esm" }, "files": [ 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..9c0c999d 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 React, { act } 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: React.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..34447c0e 100644 --- a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts +++ b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.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 React, { useEffect } from "react"; +import React from "react"; import { AccessTokenProvider, @@ -53,9 +53,10 @@ export const useIModelData = ({ 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 abortControllerRef = React.useRef( + undefined + ); + const activeRequestRef = React.useRef(undefined); const sortType = sortOptions && ["name", "createdDateTime"].includes(sortOptions.sortType) @@ -95,9 +96,6 @@ export const useIModelData = ({ setPreviousSortOptions(sortOptions); } - // cleanup the abort controller when unmounting - useEffect(() => () => abortController?.abort(), [abortController]); - const reset = React.useCallback(() => { if (dataMode === "external") { return; @@ -182,8 +180,8 @@ export const useIModelData = ({ } setNeedsUpdate(false); - abortController?.abort(); - setAbortController(undefined); + abortControllerRef.current?.abort(); + abortControllerRef.current = undefined; if (!accessToken || !iTwinId) { setStatus( @@ -202,6 +200,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 +216,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 +228,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 +237,12 @@ export const useIModelData = ({ }); }, [ dataMode, - abortController, accessToken, apiOverrides?.data, apiOverrides?.serverEnvironmentPrefix, - iModels, iTwinId, maxCount, pageSize, - morePagesAvailable, needsUpdate, page, requestType, @@ -255,6 +252,15 @@ export const useIModelData = ({ sortType, ]); + // Abort any in-flight request and invalidate stale results on unmount + React.useEffect( + () => () => { + activeRequestRef.current = undefined; + abortControllerRef.current?.abort(); + }, + [] + ); + if (dataMode === "external") { return { iModels: apiOverrides?.data ?? [], From b6887f4886ea21ab12b61fc095885c47dc51fedc Mon Sep 17 00:00:00 2001 From: Nam Le <50554904+hl662@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:32:33 -0400 Subject: [PATCH 2/7] chore(imodel-browser): revert --forceExit from test script Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/modules/imodel-browser/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/imodel-browser/package.json b/packages/modules/imodel-browser/package.json index 1fe4b663..ca3f1c9d 100644 --- a/packages/modules/imodel-browser/package.json +++ b/packages/modules/imodel-browser/package.json @@ -33,7 +33,7 @@ "scripts": { "start": "rollup -cw", "build": "rollup --silent -c", - "test": "jest --silent --forceExit", + "test": "jest --silent", "clean": "rimraf cjs esm" }, "files": [ From 9c48e895253aa32aaf6ff6ddff2210a8273a77d2 Mon Sep 17 00:00:00 2001 From: Nam Le <50554904+hl662@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:33:24 -0400 Subject: [PATCH 3/7] chore: revert pnpm-lock.yaml from .gitignore Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 5f5af904..ebf38920 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,6 @@ yarn-error.log* # Dependency directories node_modules/ -# Per-package lockfiles (Rush manages a single lockfile at common/config/rush/pnpm-lock.yaml) -pnpm-lock.yaml - # Optional npm cache directory .npm From bae47ae50085d0db0117d6bcb826c5ced96fbd3b Mon Sep 17 00:00:00 2001 From: Nam Le <50554904+hl662@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:51:11 -0400 Subject: [PATCH 4/7] refactor(imodel-browser): destructure StrictMode from react import Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/containers/iModelGrid/useIModelData.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 9c0c999d..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 React, { act } from "react"; +import { act, StrictMode } from "react"; import { server } from "../../tests/mocks/server"; import { DataStatus } from "../../types"; @@ -317,7 +317,7 @@ describe("useIModelData hook", () => { it("completes fetch when mounted inside React.StrictMode", async () => { const { result, waitForValueToChange } = renderHook( () => useIModelData({ iTwinId: "iTwinId", accessToken: "accessToken" }), - { wrapper: React.StrictMode } + { wrapper: StrictMode } ); await waitForValueToChange( From 1a59716ea7f235fc27a94e728e80841e655e13ef Mon Sep 17 00:00:00 2001 From: Nam Le <50554904+hl662@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:28:33 -0400 Subject: [PATCH 5/7] chore: update CI to Node 24.x and add rush change file for imodel-browser-react Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- ...-abort-controller-strict-mode_2026-06-26-14-27.json | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 common/changes/@itwin/imodel-browser-react/hl662-fix-abort-controller-strict-mode_2026-06-26-14-27.json 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" +} From 474107a20643d020efb4073c59518a4e022ffd69 Mon Sep 17 00:00:00 2001 From: Nam Le <50554904+hl662@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:34:52 -0400 Subject: [PATCH 6/7] refactor(imodel-browser): use named React hook imports instead of React namespace Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../containers/iModelGrid/useIModelData.ts | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts b/packages/modules/imodel-browser/src/containers/iModelGrid/useIModelData.ts index 34447c0e..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 from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { AccessTokenProvider, @@ -48,21 +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 abortControllerRef = React.useRef( + 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 = React.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; @@ -73,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") @@ -96,7 +102,7 @@ export const useIModelData = ({ setPreviousSortOptions(sortOptions); } - const reset = React.useCallback(() => { + const reset = useCallback(() => { if (dataMode === "external") { return; } @@ -108,7 +114,7 @@ export const useIModelData = ({ setNeedsUpdate(true); }, [dataMode]); - const fetchMore = React.useCallback(() => { + const fetchMore = useCallback(() => { if (dataMode === "external") { return; } @@ -126,7 +132,7 @@ export const useIModelData = ({ setNeedsUpdate(true); }, [dataMode, needsUpdate, status, morePagesAvailable]); - React.useEffect(() => { + useEffect(() => { if (dataMode === "external") { return; } @@ -150,7 +156,7 @@ export const useIModelData = ({ reset, ]); - React.useEffect(() => { + useEffect(() => { if (dataMode === "external") { return; } @@ -174,7 +180,7 @@ export const useIModelData = ({ ]); // Main function - React.useEffect(() => { + useEffect(() => { if (dataMode === "external" || !needsUpdate) { return; } @@ -253,7 +259,7 @@ export const useIModelData = ({ ]); // Abort any in-flight request and invalidate stale results on unmount - React.useEffect( + useEffect( () => () => { activeRequestRef.current = undefined; abortControllerRef.current?.abort(); From 22270f30bde662fded408888ffb4e4ddf5485515 Mon Sep 17 00:00:00 2001 From: Nam Le <50554904+hl662@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:54:43 -0400 Subject: [PATCH 7/7] chore: add Node 24 to rush.json nodeSupportedVersionRange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump pnpmVersion from 7.32.2 to 7.33.7 (latest pnpm 7.x). Add ^24.11.0 to nodeSupportedVersionRange per iTwin.js supported platforms — Node 24 LTS is supported as of 24.11.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- rush.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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": {