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 }