From 354075673ff2cb255df55b98d8d795195e4f2ff1 Mon Sep 17 00:00:00 2001 From: Lukas Elmer Date: Fri, 7 Aug 2026 20:48:57 +0200 Subject: [PATCH 1/2] Upgrade to tsdx 2 so builds emit no invalid pure annotation Bundlers that follow pure annotations strictly, such as Rolldown (Vite 8), warn on every build: [INVALID_ANNOTATION] A comment "/*#__PURE__*/" in "react-async-hook/dist/react-async-hook.esm.js" contains an annotation that Rolldown cannot interpret due to the position of the comment. The two offending lines are _iteratorSymbol and _asyncIteratorSymbol from babel-plugin-transform-async-to-promises. tsdx 0.x ran that plugin to downlevel async to ES5, and a try block inside an async function made it pull in its helper module, which carries those two declarations. The annotation sits on a conditional expression, where it means nothing, so bundlers both reject it and keep the dead code it was meant to let them drop. tsdx 2 replaces the rollup and babel pipeline with bunchee, and the tsconfig target moves to ES2022, so async, await and try/finally survive to the output untouched. No downleveling, no helper module, no annotation. The hooks keep their exact source: this is a toolchain change only. The target matters as much as the tooling. bunchee reads the tsconfig, so at the old es5 target it still downleveled, only with SWC helpers rather than babel ones. That alone removes the warning, since the SWC helpers carry no misplaced annotation, but it keeps a needless helper chunk next to the bundle. ES2022 drops both. - tsdx 0.7.2 to 2.0.0. It shells out to bunchee, vitest and oxlint through bunx without depending on them, so bunchee and vitest are declared here; without them the build and the test run fail with ENOENT - typescript 3.4.5 to 6.0.2, the newest release that still ships the JavaScript compiler API bunchee needs to emit declarations. TypeScript 7 is the native port and drops that API, so it additionally requires the @typescript/typescript6 compatibility package; staying on 6 keeps a single TypeScript dependency - tsconfig target ES2022 and moduleResolution bundler, matching what tsdx 2 documents. importHelpers goes with tslib, which nothing needs once there is no downleveling - tests move from jest to vitest, which is what tsdx 2 runs: jest.fn becomes vi.fn, and vitest.config.mts supplies the globals and the jsdom environment that tsdx 0.x passed as --env=jsdom. The config is .mts because this package is not type: module - @types/jest and tslib go, both left without a consumer - node engine to >=20, as required by tsdx 2 Output filenames are unchanged, because bunchee reads main and module. Adds a types field next to typings, which bunchee needs to emit declarations. Adds the first tests for useAsyncAbortable, covering the signal, the abort on params change, a synchronous throw and a non-promise return. Verified with Rolldown: 2 INVALID_ANNOTATION warnings before, no warnings at all after. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 15 +++--- test/useAsync.test.ts | 110 ++++++++++++++++++++++++++++++++++++------ tsconfig.json | 7 ++- vitest.config.mts | 8 +++ 4 files changed, 113 insertions(+), 27 deletions(-) create mode 100644 vitest.config.mts diff --git a/package.json b/package.json index bfbd82a..3260832 100644 --- a/package.json +++ b/package.json @@ -27,19 +27,19 @@ "use-promise" ], "engines": { - "node": ">=8", - "npm": ">=5" + "node": ">=20" }, "main": "dist/index.js", "module": "dist/react-async-hook.esm.js", "typings": "dist/index.d.ts", + "types": "dist/index.d.ts", "files": [ "dist" ], "scripts": { "start": "tsdx watch", "build": "tsdx build", - "test": "tsdx test --env=jsdom" + "test": "tsdx test" }, "peerDependencies": { "react": ">=16.8" @@ -69,17 +69,18 @@ "@testing-library/jest-dom": "^4.1.2", "@testing-library/react": "^9.3.0", "@testing-library/react-hooks": "^3.1.0", - "@types/jest": "^24.0.12", "@types/react": "^16.9.9", "@types/react-dom": "^16.9.2", + "bunchee": "^7.0.0", "husky": "^2.2.0", + "jsdom": "^30.0.1", "prettier": "^1.17.0", "pretty-quick": "^1.10.0", "react": "^16.10.2", "react-dom": "^16.10.2", "react-test-renderer": "^16.10.2", - "tsdx": "^0.7.2", - "tslib": "^1.9.3", - "typescript": "^3.4.5" + "tsdx": "^2.0.0", + "typescript": "^6.0.2", + "vitest": "^4.1.10" } } diff --git a/test/useAsync.test.ts b/test/useAsync.test.ts index 7e163c3..c53bf89 100644 --- a/test/useAsync.test.ts +++ b/test/useAsync.test.ts @@ -1,4 +1,4 @@ -import { useAsync } from '../src'; +import { useAsync, useAsyncAbortable } from '../src'; import { renderHook } from '@testing-library/react-hooks'; const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); @@ -30,8 +30,8 @@ describe('useAync', () => { }); it('should resolve a successful resolved promise', async () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); + const onSuccess = vi.fn(); + const onError = vi.fn(); const { result, waitForNextUpdate } = renderHook(() => useAsync( @@ -58,8 +58,8 @@ describe('useAync', () => { }); it('should resolve a successful real-world request + handle params update', async () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); + const onSuccess = vi.fn(); + const onError = vi.fn(); const { result, waitForNextUpdate, rerender } = renderHook( ({ pageSize }: { pageSize: number }) => @@ -114,8 +114,8 @@ describe('useAync', () => { }); it('should resolve a successful real-world requests with potential race conditions', async () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); + const onSuccess = vi.fn(); + const onError = vi.fn(); const { result, waitForNextUpdate, rerender } = renderHook( ({ pageSize, delay }: { pageSize: number; delay: number }) => @@ -165,10 +165,10 @@ describe('useAync', () => { // This test ensures better testability of user code // See https://github.com/slorber/react-async-hook/issues/24 it('should resolve a successful Jest mocked resolved value', async () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); + const onSuccess = vi.fn(); + const onError = vi.fn(); - const asyncFunction = jest.fn().mockResolvedValue(fakeResults); + const asyncFunction = vi.fn().mockResolvedValue(fakeResults); const { result, waitForNextUpdate } = renderHook(() => useAsync(asyncFunction, [], { @@ -190,8 +190,8 @@ describe('useAync', () => { // TODO legacy: should we remove this behavior? it('should resolve a successful synchronous request', async () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); + const onSuccess = vi.fn(); + const onError = vi.fn(); const { result, waitForNextUpdate } = renderHook(() => useAsync( @@ -217,8 +217,8 @@ describe('useAync', () => { }); it('should set error detail for unsuccessful request', async () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); + const onSuccess = vi.fn(); + const onError = vi.fn(); const { result, waitForNextUpdate } = renderHook(() => useAsync( @@ -244,8 +244,8 @@ describe('useAync', () => { }); it('should set error detail for error thrown synchronously (like when preparing/formatting a payload)', async () => { - const onSuccess = jest.fn(); - const onError = jest.fn(); + const onSuccess = vi.fn(); + const onError = vi.fn(); const { result, waitForNextUpdate } = renderHook(() => useAsync( @@ -270,3 +270,81 @@ describe('useAync', () => { expect(onError).toHaveBeenCalled(); }); }); + +describe('useAsyncAbortable', () => { + const fakeResults = generateFakeResults(); + + it('should resolve and pass a non-aborted signal', async () => { + let receivedSignal: AbortSignal | undefined; + + const { result, waitForNextUpdate } = renderHook(() => + useAsyncAbortable(async signal => { + receivedSignal = signal; + return fakeResults; + }, []) + ); + + await waitForNextUpdate(); + + expect(result.current.result).toEqual(fakeResults); + expect(result.current.error).toBeUndefined(); + expect(receivedSignal!.aborted).toBe(false); + }); + + it('should abort the previous call when params change', async () => { + const signals: AbortSignal[] = []; + + const { waitForNextUpdate, rerender } = renderHook( + ({ query }) => + useAsyncAbortable( + async signal => { + signals.push(signal); + await sleep(50); + return query; + }, + [query] + ), + { initialProps: { query: 'first' } } + ); + + rerender({ query: 'second' }); + + // the first signal is aborted synchronously, before the second call resolves + expect(signals).toHaveLength(2); + expect(signals[0].aborted).toBe(true); + expect(signals[1].aborted).toBe(false); + + await waitForNextUpdate(); + + expect(signals[1].aborted).toBe(false); + }); + + it('should set error detail for error thrown synchronously', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useAsyncAbortable(() => { + throw new Error('something went wrong'); + }, []) + ); + + await waitForNextUpdate(); + + expect(result.current.error).toBeDefined(); + expect(result.current.error!.message).toBe('something went wrong'); + expect(result.current.result).toBeUndefined(); + }); + + it('should resolve a function that does not return a promise', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useAsyncAbortable( + // @ts-ignore: not allowed by TS on purpose, but still allowed at runtime + () => fakeResults, + [] + ) + ); + + await waitForNextUpdate(); + + expect(result.current.result).toEqual(fakeResults); + expect(result.current.error).toBeUndefined(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 8e5b21c..793e4ba 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,10 +2,9 @@ "include": ["src", "types"], "exclude": ["test"], "compilerOptions": { - "target": "es5", - "module": "esnext", + "target": "ES2022", + "module": "ESNext", "lib": ["dom", "esnext"], - "importHelpers": true, "declaration": true, "sourceMap": true, "rootDir": "./", @@ -20,7 +19,7 @@ "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "baseUrl": "./", "paths": { "*": ["src/*", "node_modules/*"] diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..5270893 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + }, +}); From c892bdf9d4a1d3ff64928f7b33d7790234e1fcac Mon Sep 17 00:00:00 2001 From: Lukas Elmer Date: Fri, 7 Aug 2026 22:01:25 +0200 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- test/useAsync.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/useAsync.test.ts b/test/useAsync.test.ts index c53bf89..65a8bec 100644 --- a/test/useAsync.test.ts +++ b/test/useAsync.test.ts @@ -1,5 +1,6 @@ import { useAsync, useAsyncAbortable } from '../src'; import { renderHook } from '@testing-library/react-hooks'; +import { describe, expect, it, vi } from 'vitest'; const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));