From 3e910f86a168bd2ededb19e974a405bf7b62daf1 Mon Sep 17 00:00:00 2001
From: Tane Morgan <464864+tanem@users.noreply.github.com>
Date: Sat, 21 Feb 2026 07:21:47 +1300
Subject: [PATCH 1/2] Align logic more closely with the original nprogress
---
.github/copilot-instructions.md | 31 +++++++++++----
MIGRATION.md | 30 ++++++++++++++
README.md | 2 +-
src/createTimeout.ts | 7 +++-
src/useNProgress.tsx | 25 +++++++-----
test/NProgress.spec.tsx | 18 +++++++++
test/increment.spec.ts | 19 ++++++++-
test/queue.spec.ts | 9 ++++-
test/timeout.spec.ts | 14 ++++++-
test/useNProgress.spec.ts | 69 +++++++++++++++++++++++++++++++--
test/withNProgress.spec.tsx | 16 ++++++++
11 files changed, 214 insertions(+), 26 deletions(-)
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index fda04c747..14d00ab9f 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -7,7 +7,7 @@ changes agent behaviour and cannot be inferred from the codebase or tooling.
TypeScript React library providing a slim progress bar primitive via three
patterns: `useNProgress` hook, `NProgress` render-props component, and
-`withNProgress` HOC. Exports logic only — no rendering. All exports go through
+`withNProgress` HOC. Exports logic only, not rendering. All exports go through
`src/index.tsx`. Types live in `src/types.ts`.
## Key Commands
@@ -22,14 +22,15 @@ npm run format # fix lint and formatting
### Comments
-- Use `//` line comments only — never `/* */` or `/** */`
+- Use `//` line comments only, never `/* */` or `/** */`
- Explain _why_, not _what_; wrap at 80 characters
+- End every comment with a full stop, even single-line comments
### Language
Use **New Zealand English** in all user-facing text, variable names, and
comments (e.g. "colour", "behaviour", "organisation"). Standardised API names
-(`color`, `textAlign`) are fixed — leave them unchanged.
+(`color`, `textAlign`) are fixed: leave them unchanged.
```javascript
const progressColour = '#0066cc'
@@ -49,9 +50,9 @@ subject, no trailing period, blank line between subject and body.
Managed by Renovate (`config:js-lib` preset):
-- `devDependencies` — pinned exact versions (no `^` or `~`)
-- `dependencies` — caret ranges (`^`)
-- `peerDependencies` — explicit OR ranges (e.g. `^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0`)
+- `devDependencies`: pinned exact versions (no `^` or `~`)
+- `dependencies`: caret ranges (`^`)
+- `peerDependencies`: explicit OR ranges (e.g. `^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0`)
- Do **not** add `allowedVersions` to `renovate.json` without a documented reason
## Testing
@@ -85,7 +86,23 @@ needed but test on CodeSandbox before merging.
Do not bump vite, @vitejs/plugin-react, next, or typescript in examples
beyond the versions in the reference templates.
+## Writing Style
+
+- Avoid marketing or promotional language. State facts plainly.
+- Follow best practices for technical writing: be clear, direct, and
+ concise.
+- Avoid em dashes. Use colons, commas, or separate sentences instead.
+- Use present tense and active voice where practical.
+- Keep sentences short. One idea per sentence.
+
## Versioning
-Strict semver — no breaking changes without a major version bump, including
+Strict semver: no breaking changes without a major version bump, including
technical refactors.
+
+## Documentation
+
+- After each code change, update all related docs and markdown files
+ (README.md, MIGRATION.md, example READMEs, etc.) in the same pass.
+- Do not manually modify CHANGELOG.md. It is auto-generated during
+ release.
diff --git a/MIGRATION.md b/MIGRATION.md
index 181a724b7..b5333df0d 100644
--- a/MIGRATION.md
+++ b/MIGRATION.md
@@ -1,5 +1,35 @@
# Migrating
+## v6.0.0
+
+Trickle pacing was adjusted to more closely match the original
+[nprogress](https://github.com/rstacruz/nprogress) behaviour.
+
+### `incrementDuration` default changed from `800` to `200`
+
+The previous default of `800` meant each trickle took roughly one second
+(animation wait plus increment delay). The new default of `200` matches
+the original library's `trickleSpeed` and results in faster trickle
+pacing.
+
+**Action required:** if you were relying on the old default pacing,
+explicitly pass `incrementDuration={800}` to restore the previous
+behaviour.
+
+### Intermediate progress updates no longer wait for `animationDuration`
+
+Non-completion `set()` calls previously waited `animationDuration`
+(200 ms) before advancing the internal queue. This wait has been
+removed for intermediate updates so that only `incrementDuration`
+controls trickle pacing. The completion path (`set(1)`) still waits
+`animationDuration` before marking the bar as finished, giving
+consumers time to animate the bar to 100% before it disappears.
+
+**Action required:** none in most cases. If your rendering code relied
+on intermediate progress updates being spaced at least
+`animationDuration` apart, you may need to adjust your
+transition/animation timing.
+
## v5.0.0
The prop-types package is no longer required for using the UMD builds.
diff --git a/README.md b/README.md
index d476a6576..d65d5ebd9 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,7 @@ render(, document.getElementById('root'))
**Props**
- `animationDuration` - _Optional_ Number indicating the animation duration in `ms`. Defaults to `200`.
-- `incrementDuration` - _Optional_ Number indicating the length of time between progress bar increments in `ms`. Defaults to `800`.
+- `incrementDuration` - _Optional_ Number indicating the length of time between progress bar increments in `ms`. Defaults to `200`.
- `isAnimating` - _Optional_ Boolean indicating if the progress bar is animating. Defaults to `false`.
- `minimum` - _Optional_ Number between `0` and `1` indicating the minimum value of the progress bar. Defaults to `0.08`.
diff --git a/src/createTimeout.ts b/src/createTimeout.ts
index 44a49fb69..e55ff5abd 100644
--- a/src/createTimeout.ts
+++ b/src/createTimeout.ts
@@ -1,13 +1,18 @@
+// Uses requestAnimationFrame rather than setTimeout for smoother animation
+// timing. Note that rAF is throttled or paused in background tabs, so progress
+// will stall when the tab is hidden and resume when it regains focus.
export const createTimeout = () => {
let handle: number | undefined
const cancel = (): void => {
- if (handle) {
+ if (handle !== undefined) {
window.cancelAnimationFrame(handle)
}
}
const schedule = (callback: () => void, delay: number): void => {
+ cancel()
+
let deltaTime
let start: number | undefined
const frame: FrameRequestCallback = (time) => {
diff --git a/src/useNProgress.tsx b/src/useNProgress.tsx
index 744b197cf..8dff331a3 100644
--- a/src/useNProgress.tsx
+++ b/src/useNProgress.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef } from 'react'
+import { useCallback, useRef } from 'react'
import { clamp } from './clamp'
import { createQueue } from './createQueue'
@@ -12,6 +12,11 @@ import { useUpdateEffect } from './useUpdateEffect'
/* istanbul ignore next */
const noop = () => undefined
+// State includes a `sideEffect` callback that bridges imperative queue
+// operations into React's declarative model. Each `setState` stores a callback;
+// `useUpdateEffect` fires it after React commits the update. This lets the
+// queue drive animations and schedule follow-up work without breaking React's
+// rendering lifecycle.
const initialState: {
isFinished: boolean
progress: number
@@ -24,7 +29,7 @@ const initialState: {
export const useNProgress = ({
animationDuration = 200,
- incrementDuration = 800,
+ incrementDuration = 200,
isAnimating = false,
minimum = 0.08,
}: Options = {}): {
@@ -51,6 +56,10 @@ export const useNProgress = ({
(n: number) => {
n = clamp(n, minimum, 1)
+ // Unlike the original nprogress `done()`, completion does not include a
+ // random progress jump before animating to 1. This keeps the primitive
+ // predictable; consumers can set a higher progress value before stopping
+ // the animation if they want that effect.
if (n === 1) {
cleanup()
@@ -73,7 +82,7 @@ export const useNProgress = ({
setState({
isFinished: false,
progress: n,
- sideEffect: () => timeout.current?.schedule(next, animationDuration),
+ sideEffect: next,
})
})
},
@@ -85,6 +94,10 @@ export const useNProgress = ({
}, [get, set])
const start = useCallback(() => {
+ // The original nprogress calls set(0) - which clamps to `minimum` - before
+ // the first trickle. Here, the first trickle starts from increment(0) =
+ // 0.1, so the bar appears at max(0.1, minimum) rather than exactly
+ // `minimum`. The difference is negligible at the default minimum of 0.08.
const work = () => {
trickle()
queue.current?.enqueue((next) => {
@@ -98,14 +111,8 @@ export const useNProgress = ({
work()
}, [incrementDuration, queue, timeout, trickle])
- const savedTrickle = useRef<() => void>(noop)
-
const sideEffect = get().sideEffect
- useEffect(() => {
- savedTrickle.current = trickle
- })
-
useEffectOnce(() => {
if (isAnimating) {
start()
diff --git a/test/NProgress.spec.tsx b/test/NProgress.spec.tsx
index 69cbf5a67..997178539 100644
--- a/test/NProgress.spec.tsx
+++ b/test/NProgress.spec.tsx
@@ -22,3 +22,21 @@ test('receives render props', () => {
expect(isFinished).toBe(true)
expect(progress).toBe(0)
})
+
+test('passes animating state to children', () => {
+ let isFinished
+ let progress
+
+ render(
+
+ {(props) => {
+ isFinished = props.isFinished
+ progress = props.progress
+ return <>>
+ }}
+ ,
+ )
+
+ expect(isFinished).toBe(false)
+ expect(progress).toBe(0.1)
+})
diff --git a/test/increment.spec.ts b/test/increment.spec.ts
index e2884bd02..4719033c6 100644
--- a/test/increment.spec.ts
+++ b/test/increment.spec.ts
@@ -1,10 +1,27 @@
import { increment } from '../src/increment'
-test('increments corrrectly', () => {
+test('increments correctly', () => {
+ // Below zero.
expect(increment(-1)).toBeCloseTo(0)
+
+ // [0, 0.2): amount = 0.1.
expect(increment(0)).toBeCloseTo(0.1)
+ expect(increment(0.19)).toBeCloseTo(0.29)
+
+ // [0.2, 0.5): amount = 0.04.
expect(increment(0.2)).toBeCloseTo(0.24)
+ expect(increment(0.49)).toBeCloseTo(0.53)
+
+ // [0.5, 0.8): amount = 0.02.
expect(increment(0.5)).toBeCloseTo(0.52)
+ expect(increment(0.79)).toBeCloseTo(0.81)
+
+ // [0.8, 0.99): amount = 0.005.
expect(increment(0.8)).toBeCloseTo(0.805)
+ expect(increment(0.989)).toBeCloseTo(0.994)
+
+ // >= 0.99: amount = 0, clamped to 0.994.
+ expect(increment(0.99)).toBeCloseTo(0.99)
+ expect(increment(0.994)).toBeCloseTo(0.994)
expect(increment(1)).toBeCloseTo(0.994)
})
diff --git a/test/queue.spec.ts b/test/queue.spec.ts
index 3cf285327..734cf6a9b 100644
--- a/test/queue.spec.ts
+++ b/test/queue.spec.ts
@@ -1,9 +1,14 @@
import { createQueue } from '../src/createQueue'
-const { clear, enqueue } = createQueue()
-
jest.useFakeTimers()
+let clear: ReturnType['clear']
+let enqueue: ReturnType['enqueue']
+
+beforeEach(() => {
+ ;({ clear, enqueue } = createQueue())
+})
+
test('starts running when the first callback is pushed onto the queue', () => {
const mockFn = jest.fn()
diff --git a/test/timeout.spec.ts b/test/timeout.spec.ts
index 1d8d90b29..1c5db2ea2 100644
--- a/test/timeout.spec.ts
+++ b/test/timeout.spec.ts
@@ -14,7 +14,7 @@ test('executes a callback after a delay', () => {
schedule(mockFn, 10)
mockRaf.step()
mockRaf.step()
- expect(mockFn).toHaveBeenCalled()
+ expect(mockFn).toHaveBeenCalledTimes(1)
})
test('can cancel a pending callback', () => {
@@ -25,3 +25,15 @@ test('can cancel a pending callback', () => {
mockRaf.step()
expect(mockFn).not.toHaveBeenCalled()
})
+
+test('cancels a pending callback when rescheduling', () => {
+ const mockFn1 = jest.fn()
+ const mockFn2 = jest.fn()
+ schedule(mockFn1, 10)
+ mockRaf.step()
+ schedule(mockFn2, 10)
+ mockRaf.step()
+ mockRaf.step()
+ expect(mockFn1).not.toHaveBeenCalled()
+ expect(mockFn2).toHaveBeenCalled()
+})
diff --git a/test/useNProgress.spec.ts b/test/useNProgress.spec.ts
index ac4ae0b59..02d850409 100644
--- a/test/useNProgress.spec.ts
+++ b/test/useNProgress.spec.ts
@@ -58,8 +58,6 @@ test('increments correctly', () => {
act(() => {
mockRaf.step()
mockRaf.step({ time: 201 })
- mockRaf.step()
- mockRaf.step({ time: 801 })
})
expect(result.current).toEqual({
@@ -117,8 +115,6 @@ test('correctly restarts a finished animation', () => {
act(() => {
mockRaf.step()
mockRaf.step({ time: 201 })
- mockRaf.step()
- mockRaf.step({ time: 801 })
})
expect(result.current).toEqual({
@@ -129,3 +125,68 @@ test('correctly restarts a finished animation', () => {
unmount()
})
+
+test('respects custom minimum', () => {
+ const { result, unmount } = renderHook(() =>
+ useNProgress({ isAnimating: true, minimum: 0.3 }),
+ )
+
+ // increment(0) = 0.1, clamped to minimum of 0.3.
+ expect(result.current.progress).toBe(0.3)
+
+ unmount()
+})
+
+test('respects custom animationDuration', () => {
+ const { result, rerender, unmount } = renderHook(
+ ({ isAnimating }) => useNProgress({ animationDuration: 500, isAnimating }),
+ { initialProps: { isAnimating: true } },
+ )
+
+ expect(result.current.animationDuration).toBe(500)
+
+ rerender({ isAnimating: false })
+
+ // Completion waits animationDuration (500ms) before isFinished.
+ act(() => {
+ mockRaf.step()
+ mockRaf.step({ time: 300 })
+ })
+
+ expect(result.current.isFinished).toBe(false)
+
+ act(() => {
+ mockRaf.step()
+ mockRaf.step({ time: 501 })
+ })
+
+ expect(result.current.isFinished).toBe(true)
+
+ unmount()
+})
+
+test('respects custom incrementDuration', () => {
+ const { result, unmount } = renderHook(() =>
+ useNProgress({ incrementDuration: 500, isAnimating: true }),
+ )
+
+ expect(result.current.progress).toBe(0.1)
+
+ // Not enough time for a second trickle.
+ act(() => {
+ mockRaf.step()
+ mockRaf.step({ time: 201 })
+ })
+
+ expect(result.current.progress).toBe(0.1)
+
+ // Enough time for the second trickle.
+ act(() => {
+ mockRaf.step()
+ mockRaf.step({ time: 501 })
+ })
+
+ expect(result.current.progress).toBe(0.2)
+
+ unmount()
+})
diff --git a/test/withNProgress.spec.tsx b/test/withNProgress.spec.tsx
index dab733beb..040e06e09 100644
--- a/test/withNProgress.spec.tsx
+++ b/test/withNProgress.spec.tsx
@@ -20,3 +20,19 @@ test('wrapped component receives props', () => {
expect(isFinished).toBe(true)
expect(progress).toBe(0)
})
+
+test('passes animating state to wrapped component', () => {
+ let isFinished
+ let progress
+
+ const EnhancedComponent = withNProgress((props) => {
+ isFinished = props.isFinished
+ progress = props.progress
+ return <>>
+ })
+
+ render()
+
+ expect(isFinished).toBe(false)
+ expect(progress).toBe(0.1)
+})
From b6d9a86a52955a43f2f595416a77545b2bc7d799 Mon Sep 17 00:00:00 2001
From: Tane Morgan <464864+tanem@users.noreply.github.com>
Date: Sat, 21 Feb 2026 08:02:47 +1300
Subject: [PATCH 2/2] Add react boundary tests
---
.github/copilot-instructions.md | 26 ++++++++++++
package.json | 2 +
scripts/clean-react.js | 25 ++++++++++++
scripts/jest/config.src.js | 64 ++++++++++++++++++++++++++++++
scripts/jest/setupReact.js | 15 +++++++
scripts/test-react.js | 39 ++++++++++++++++++
test/react/16.14/package.json | 10 +++++
test/react/17.0/package.json | 10 +++++
test/react/18.0/package.json | 8 ++++
test/react/18.3/package.json | 8 ++++
test/react/19.0/package.json | 8 ++++
test/react/testing-library-shim.js | 18 +++++++++
12 files changed, 233 insertions(+)
create mode 100644 scripts/clean-react.js
create mode 100644 scripts/jest/setupReact.js
create mode 100644 scripts/test-react.js
create mode 100644 test/react/16.14/package.json
create mode 100644 test/react/17.0/package.json
create mode 100644 test/react/18.0/package.json
create mode 100644 test/react/18.3/package.json
create mode 100644 test/react/19.0/package.json
create mode 100644 test/react/testing-library-shim.js
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 14d00ab9f..9784ca5a7 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -60,6 +60,32 @@ Managed by Renovate (`config:js-lib` preset):
- **100% code coverage** required across all build formats
- Always run `npm test` after changes; use `npm run test:src` for quick
source-only feedback during development
+- Use `npm run test:react` for the full React version matrix independently.
+ It also runs as part of `npm test` (via the `test:*` glob).
+
+### React version matrix
+
+We test boundary versions only: first and last minor of each supported
+major. See `test/react/` for current versions.
+
+Current boundaries: 16.14, 17.0, 18.0, 18.3, 19.0.
+
+React 16.14 is the practical lower bound. Hooks require 16.8 and
+`@testing-library/react-hooks` requires 16.9.
+
+When adding a new boundary:
+
+1. Add `test/react//package.json` with correct `react`,
+ `react-dom`, and `@testing-library/react` (12.x for React 16–17,
+ 16.x for React 18+). React 16–17 also need
+ `@testing-library/react-hooks` (8.x) and `react-test-renderer`.
+2. Replace the previous "latest minor" for that major.
+3. Verify with a single-version run before the full matrix:
+ ```bash
+ cd test/react/ && npm i --no-package-lock --quiet --no-progress
+ REACT_VERSION= npx jest --config ./scripts/jest/config.src.js --coverage false
+ ```
+4. Update the boundary list above.
## Examples
diff --git a/package.json b/package.json
index 0430c5419..48d0919d8 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"clean:compiled": "shx rm -rf compiled",
"clean:coverage": "shx rm -rf coverage",
"clean:dist": "shx rm -rf dist",
+ "clean:react": "node ./scripts/clean-react.js",
"compile": "tsc -p tsconfig.base.json",
"format": "npm run lint -- --fix && prettier --write \"**/*.{js,ts,tsx}\"",
"lint": "eslint .",
@@ -30,6 +31,7 @@
"test:cjs": "jest --config ./scripts/jest/config.cjs.js",
"test:cjsprod": "jest --config ./scripts/jest/config.cjsprod.js",
"test:es": "jest --config ./scripts/jest/config.es.js",
+ "test:react": "node ./scripts/test-react.js",
"test:src": "jest --config ./scripts/jest/config.src.js",
"test:umd": "jest --config ./scripts/jest/config.umd.js",
"test:umdprod": "jest --config ./scripts/jest/config.umdprod.js",
diff --git a/scripts/clean-react.js b/scripts/clean-react.js
new file mode 100644
index 000000000..09999bf31
--- /dev/null
+++ b/scripts/clean-react.js
@@ -0,0 +1,25 @@
+// Cleans node_modules from each test/react/ directory while preserving
+// the package.json file.
+const fs = require('fs')
+const os = require('os')
+const path = require('path')
+
+const reactDir = path.join(process.cwd(), 'test', 'react')
+
+fs.readdirSync(reactDir, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .forEach((entry) => {
+ const dir = path.join(reactDir, entry.name)
+ const pkgJsonPath = path.join(dir, 'package.json')
+
+ if (!fs.existsSync(pkgJsonPath)) {
+ return
+ }
+
+ // Preserve package.json by copying to a temporary location.
+ const tmpPath = path.join(os.tmpdir(), `${entry.name}-package.json`)
+ fs.copyFileSync(pkgJsonPath, tmpPath)
+ fs.rmSync(dir, { force: true, recursive: true })
+ fs.mkdirSync(dir, { recursive: true })
+ fs.copyFileSync(tmpPath, pkgJsonPath)
+ })
diff --git a/scripts/jest/config.src.js b/scripts/jest/config.src.js
index 89a0b7427..6097db704 100644
--- a/scripts/jest/config.src.js
+++ b/scripts/jest/config.src.js
@@ -1,10 +1,74 @@
+const path = require('path')
+
+// When REACT_VERSION is set (e.g. "18.0"), resolve react, react-dom and
+// @testing-library/react from the matching test/react/ directory so
+// the test suite runs against that specific React version.
+const generateReactVersionMappings = (reactVersion) => {
+ if (!reactVersion) {
+ return {}
+ }
+
+ const testDir = path.join(process.cwd(), 'test', 'react', reactVersion)
+ const [major] = reactVersion.split('.').map(Number)
+
+ const reactDir = path.dirname(
+ require.resolve('react/package.json', { paths: [testDir] }),
+ )
+ const reactDomDir = path.dirname(
+ require.resolve('react-dom/package.json', { paths: [testDir] }),
+ )
+
+ const mappings = {
+ '^react$': require.resolve('react', { paths: [testDir] }),
+ '^react-dom$': require.resolve('react-dom', { paths: [testDir] }),
+ '^react-dom/(.*)$': `${reactDomDir}/$1`,
+ '^react/(.*)$': `${reactDir}/$1`,
+ }
+
+ // React 16 and 17 use @testing-library/react 12.x which does not export
+ // renderHook. A shim re-exports it from @testing-library/react-hooks instead.
+ if (major < 18) {
+ mappings['^@testing-library/react$'] = path.join(
+ testDir,
+ '..',
+ 'testing-library-shim.js',
+ )
+ } else {
+ mappings['^@testing-library/react$'] = require.resolve(
+ '@testing-library/react',
+ { paths: [testDir] },
+ )
+ }
+
+ return mappings
+}
+
+// React 16 and 17 boundary tests produce harmless "wrong act()" warnings from
+// @testing-library/react-hooks. A setup file suppresses them.
+const generateSetupFiles = (reactVersion) => {
+ if (!reactVersion) {
+ return []
+ }
+
+ const [major] = reactVersion.split('.').map(Number)
+ if (major < 18) {
+ return [path.join(__dirname, 'setupReact.js')]
+ }
+
+ return []
+}
+
module.exports = {
collectCoverage: true,
collectCoverageFrom: ['src/*.{ts,tsx}'],
moduleFileExtensions: ['ts', 'tsx', 'js'],
+ moduleNameMapper: {
+ ...generateReactVersionMappings(process.env.REACT_VERSION),
+ },
preset: 'ts-jest',
rootDir: process.cwd(),
roots: ['/test'],
+ setupFilesAfterEnv: generateSetupFiles(process.env.REACT_VERSION),
testEnvironment: 'jsdom',
testMatch: ['/test/*.spec.ts?(x)'],
transform: { '^.+\\.(js|tsx?)$': 'ts-jest' },
diff --git a/scripts/jest/setupReact.js b/scripts/jest/setupReact.js
new file mode 100644
index 000000000..d3d299ff9
--- /dev/null
+++ b/scripts/jest/setupReact.js
@@ -0,0 +1,15 @@
+// Suppresses the "wrong act()" console.error warnings that
+// @testing-library/react-hooks emits on React 16 and 17. The package uses
+// react-test-renderer internally while state updates flow through react-dom,
+// triggering a harmless mismatch warning.
+const originalError = console.error
+
+console.error = (...args) => {
+ if (
+ typeof args[0] === 'string' &&
+ args[0].includes("It looks like you're using the wrong act()")
+ ) {
+ return
+ }
+ originalError.call(console, ...args)
+}
diff --git a/scripts/test-react.js b/scripts/test-react.js
new file mode 100644
index 000000000..654eb9b98
--- /dev/null
+++ b/scripts/test-react.js
@@ -0,0 +1,39 @@
+// Installs dependencies and runs the src test suite against each React version
+// defined in test/react/.
+const { execSync } = require('child_process')
+const fs = require('fs')
+const path = require('path')
+
+const reactDir = path.join(process.cwd(), 'test', 'react')
+
+const versions = fs
+ .readdirSync(reactDir, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .filter((entry) =>
+ fs.existsSync(path.join(reactDir, entry.name, 'package.json')),
+ )
+ .map((entry) => entry.name)
+ .sort()
+
+for (const version of versions) {
+ const dir = path.join(reactDir, version)
+
+ console.log(`Starting React ${version} tests`)
+
+ execSync('npm i --no-package-lock --quiet --no-progress', {
+ cwd: dir,
+ stdio: 'inherit',
+ })
+
+ try {
+ execSync(
+ `REACT_VERSION=${version} npx jest --config ./scripts/jest/config.src.js --coverage false`,
+ { stdio: 'inherit' },
+ )
+ } catch {
+ console.error(`Fail testing React ${version}`)
+ process.exit(1)
+ }
+
+ console.log(`Success testing React ${version}`)
+}
diff --git a/test/react/16.14/package.json b/test/react/16.14/package.json
new file mode 100644
index 000000000..d6058a73d
--- /dev/null
+++ b/test/react/16.14/package.json
@@ -0,0 +1,10 @@
+{
+ "private": true,
+ "devDependencies": {
+ "@testing-library/react": "12.x",
+ "@testing-library/react-hooks": "8.x",
+ "react": "16.14.x",
+ "react-dom": "16.14.x",
+ "react-test-renderer": "16.14.x"
+ }
+}
diff --git a/test/react/17.0/package.json b/test/react/17.0/package.json
new file mode 100644
index 000000000..723da1d8b
--- /dev/null
+++ b/test/react/17.0/package.json
@@ -0,0 +1,10 @@
+{
+ "private": true,
+ "devDependencies": {
+ "@testing-library/react": "12.x",
+ "@testing-library/react-hooks": "8.x",
+ "react": "17.0.x",
+ "react-dom": "17.0.x",
+ "react-test-renderer": "17.0.x"
+ }
+}
diff --git a/test/react/18.0/package.json b/test/react/18.0/package.json
new file mode 100644
index 000000000..9fe89dec4
--- /dev/null
+++ b/test/react/18.0/package.json
@@ -0,0 +1,8 @@
+{
+ "private": true,
+ "devDependencies": {
+ "@testing-library/react": "16.x",
+ "react": "18.0.x",
+ "react-dom": "18.0.x"
+ }
+}
diff --git a/test/react/18.3/package.json b/test/react/18.3/package.json
new file mode 100644
index 000000000..d9b2f09fc
--- /dev/null
+++ b/test/react/18.3/package.json
@@ -0,0 +1,8 @@
+{
+ "private": true,
+ "devDependencies": {
+ "@testing-library/react": "16.x",
+ "react": "18.3.x",
+ "react-dom": "18.3.x"
+ }
+}
diff --git a/test/react/19.0/package.json b/test/react/19.0/package.json
new file mode 100644
index 000000000..9d0c12672
--- /dev/null
+++ b/test/react/19.0/package.json
@@ -0,0 +1,8 @@
+{
+ "private": true,
+ "devDependencies": {
+ "@testing-library/react": "16.x",
+ "react": "19.0.x",
+ "react-dom": "19.0.x"
+ }
+}
diff --git a/test/react/testing-library-shim.js b/test/react/testing-library-shim.js
new file mode 100644
index 000000000..a344b1674
--- /dev/null
+++ b/test/react/testing-library-shim.js
@@ -0,0 +1,18 @@
+// Shim that provides a unified @testing-library/react interface for React
+// versions where renderHook is not built into @testing-library/react (pre-v13).
+// Re-exports everything from @testing-library/react and adds renderHook from
+// the standalone @testing-library/react-hooks package.
+//
+// REACT_VERSION must be set so the shim can locate the correct version-specific
+// node_modules directory.
+const path = require('path')
+
+const testDir = path.join(__dirname, process.env.REACT_VERSION)
+const testingLibrary = require(
+ require.resolve('@testing-library/react', { paths: [testDir] }),
+)
+const { renderHook } = require(
+ require.resolve('@testing-library/react-hooks', { paths: [testDir] }),
+)
+
+module.exports = { ...testingLibrary, renderHook }