Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 50 additions & 7 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand All @@ -49,16 +50,42 @@ 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

- **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/<version>/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/<version> && npm i --no-package-lock --quiet --no-progress
REACT_VERSION=<version> npx jest --config ./scripts/jest/config.src.js --coverage false
```
4. Update the boundary list above.

## Examples

Expand All @@ -85,7 +112,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.
30 changes: 30 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ render(<Enhanced isAnimating />, 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`.

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand All @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions scripts/clean-react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Cleans node_modules from each test/react/<version> 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)
})
64 changes: 64 additions & 0 deletions scripts/jest/config.src.js
Original file line number Diff line number Diff line change
@@ -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/<version> 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: ['<rootDir>/test'],
setupFilesAfterEnv: generateSetupFiles(process.env.REACT_VERSION),
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/test/*.spec.ts?(x)'],
transform: { '^.+\\.(js|tsx?)$': 'ts-jest' },
Expand Down
15 changes: 15 additions & 0 deletions scripts/jest/setupReact.js
Original file line number Diff line number Diff line change
@@ -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)
}
39 changes: 39 additions & 0 deletions scripts/test-react.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Installs dependencies and runs the src test suite against each React version
// defined in test/react/<version>.
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}`)
}
7 changes: 6 additions & 1 deletion src/createTimeout.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down
Loading