Skip to content

Commit 3e910f8

Browse files
committed
Align logic more closely with the original nprogress
1 parent b526ee1 commit 3e910f8

11 files changed

Lines changed: 214 additions & 26 deletions

.github/copilot-instructions.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ changes agent behaviour and cannot be inferred from the codebase or tooling.
77

88
TypeScript React library providing a slim progress bar primitive via three
99
patterns: `useNProgress` hook, `NProgress` render-props component, and
10-
`withNProgress` HOC. Exports logic only — no rendering. All exports go through
10+
`withNProgress` HOC. Exports logic only, not rendering. All exports go through
1111
`src/index.tsx`. Types live in `src/types.ts`.
1212

1313
## Key Commands
@@ -22,14 +22,15 @@ npm run format # fix lint and formatting
2222

2323
### Comments
2424

25-
- Use `//` line comments only never `/* */` or `/** */`
25+
- Use `//` line comments only, never `/* */` or `/** */`
2626
- Explain _why_, not _what_; wrap at 80 characters
27+
- End every comment with a full stop, even single-line comments
2728

2829
### Language
2930

3031
Use **New Zealand English** in all user-facing text, variable names, and
3132
comments (e.g. "colour", "behaviour", "organisation"). Standardised API names
32-
(`color`, `textAlign`) are fixed leave them unchanged.
33+
(`color`, `textAlign`) are fixed: leave them unchanged.
3334

3435
```javascript
3536
const progressColour = '#0066cc'
@@ -49,9 +50,9 @@ subject, no trailing period, blank line between subject and body.
4950

5051
Managed by Renovate (`config:js-lib` preset):
5152

52-
- `devDependencies` pinned exact versions (no `^` or `~`)
53-
- `dependencies` caret ranges (`^`)
54-
- `peerDependencies` explicit OR ranges (e.g. `^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0`)
53+
- `devDependencies`: pinned exact versions (no `^` or `~`)
54+
- `dependencies`: caret ranges (`^`)
55+
- `peerDependencies`: explicit OR ranges (e.g. `^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0`)
5556
- Do **not** add `allowedVersions` to `renovate.json` without a documented reason
5657

5758
## Testing
@@ -85,7 +86,23 @@ needed but test on CodeSandbox before merging.
8586
Do not bump vite, @vitejs/plugin-react, next, or typescript in examples
8687
beyond the versions in the reference templates.
8788

89+
## Writing Style
90+
91+
- Avoid marketing or promotional language. State facts plainly.
92+
- Follow best practices for technical writing: be clear, direct, and
93+
concise.
94+
- Avoid em dashes. Use colons, commas, or separate sentences instead.
95+
- Use present tense and active voice where practical.
96+
- Keep sentences short. One idea per sentence.
97+
8898
## Versioning
8999

90-
Strict semver no breaking changes without a major version bump, including
100+
Strict semver: no breaking changes without a major version bump, including
91101
technical refactors.
102+
103+
## Documentation
104+
105+
- After each code change, update all related docs and markdown files
106+
(README.md, MIGRATION.md, example READMEs, etc.) in the same pass.
107+
- Do not manually modify CHANGELOG.md. It is auto-generated during
108+
release.

MIGRATION.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
# Migrating
22

3+
## v6.0.0
4+
5+
Trickle pacing was adjusted to more closely match the original
6+
[nprogress](https://github.com/rstacruz/nprogress) behaviour.
7+
8+
### `incrementDuration` default changed from `800` to `200`
9+
10+
The previous default of `800` meant each trickle took roughly one second
11+
(animation wait plus increment delay). The new default of `200` matches
12+
the original library's `trickleSpeed` and results in faster trickle
13+
pacing.
14+
15+
**Action required:** if you were relying on the old default pacing,
16+
explicitly pass `incrementDuration={800}` to restore the previous
17+
behaviour.
18+
19+
### Intermediate progress updates no longer wait for `animationDuration`
20+
21+
Non-completion `set()` calls previously waited `animationDuration`
22+
(200 ms) before advancing the internal queue. This wait has been
23+
removed for intermediate updates so that only `incrementDuration`
24+
controls trickle pacing. The completion path (`set(1)`) still waits
25+
`animationDuration` before marking the bar as finished, giving
26+
consumers time to animate the bar to 100% before it disappears.
27+
28+
**Action required:** none in most cases. If your rendering code relied
29+
on intermediate progress updates being spaced at least
30+
`animationDuration` apart, you may need to adjust your
31+
transition/animation timing.
32+
333
## v5.0.0
434

535
The prop-types package is no longer required for using the UMD builds.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ render(<Enhanced isAnimating />, document.getElementById('root'))
111111
**Props**
112112

113113
- `animationDuration` - _Optional_ Number indicating the animation duration in `ms`. Defaults to `200`.
114-
- `incrementDuration` - _Optional_ Number indicating the length of time between progress bar increments in `ms`. Defaults to `800`.
114+
- `incrementDuration` - _Optional_ Number indicating the length of time between progress bar increments in `ms`. Defaults to `200`.
115115
- `isAnimating` - _Optional_ Boolean indicating if the progress bar is animating. Defaults to `false`.
116116
- `minimum` - _Optional_ Number between `0` and `1` indicating the minimum value of the progress bar. Defaults to `0.08`.
117117

src/createTimeout.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
1+
// Uses requestAnimationFrame rather than setTimeout for smoother animation
2+
// timing. Note that rAF is throttled or paused in background tabs, so progress
3+
// will stall when the tab is hidden and resume when it regains focus.
14
export const createTimeout = () => {
25
let handle: number | undefined
36

47
const cancel = (): void => {
5-
if (handle) {
8+
if (handle !== undefined) {
69
window.cancelAnimationFrame(handle)
710
}
811
}
912

1013
const schedule = (callback: () => void, delay: number): void => {
14+
cancel()
15+
1116
let deltaTime
1217
let start: number | undefined
1318
const frame: FrameRequestCallback = (time) => {

src/useNProgress.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useRef } from 'react'
1+
import { useCallback, useRef } from 'react'
22

33
import { clamp } from './clamp'
44
import { createQueue } from './createQueue'
@@ -12,6 +12,11 @@ import { useUpdateEffect } from './useUpdateEffect'
1212
/* istanbul ignore next */
1313
const noop = () => undefined
1414

15+
// State includes a `sideEffect` callback that bridges imperative queue
16+
// operations into React's declarative model. Each `setState` stores a callback;
17+
// `useUpdateEffect` fires it after React commits the update. This lets the
18+
// queue drive animations and schedule follow-up work without breaking React's
19+
// rendering lifecycle.
1520
const initialState: {
1621
isFinished: boolean
1722
progress: number
@@ -24,7 +29,7 @@ const initialState: {
2429

2530
export const useNProgress = ({
2631
animationDuration = 200,
27-
incrementDuration = 800,
32+
incrementDuration = 200,
2833
isAnimating = false,
2934
minimum = 0.08,
3035
}: Options = {}): {
@@ -51,6 +56,10 @@ export const useNProgress = ({
5156
(n: number) => {
5257
n = clamp(n, minimum, 1)
5358

59+
// Unlike the original nprogress `done()`, completion does not include a
60+
// random progress jump before animating to 1. This keeps the primitive
61+
// predictable; consumers can set a higher progress value before stopping
62+
// the animation if they want that effect.
5463
if (n === 1) {
5564
cleanup()
5665

@@ -73,7 +82,7 @@ export const useNProgress = ({
7382
setState({
7483
isFinished: false,
7584
progress: n,
76-
sideEffect: () => timeout.current?.schedule(next, animationDuration),
85+
sideEffect: next,
7786
})
7887
})
7988
},
@@ -85,6 +94,10 @@ export const useNProgress = ({
8594
}, [get, set])
8695

8796
const start = useCallback(() => {
97+
// The original nprogress calls set(0) - which clamps to `minimum` - before
98+
// the first trickle. Here, the first trickle starts from increment(0) =
99+
// 0.1, so the bar appears at max(0.1, minimum) rather than exactly
100+
// `minimum`. The difference is negligible at the default minimum of 0.08.
88101
const work = () => {
89102
trickle()
90103
queue.current?.enqueue((next) => {
@@ -98,14 +111,8 @@ export const useNProgress = ({
98111
work()
99112
}, [incrementDuration, queue, timeout, trickle])
100113

101-
const savedTrickle = useRef<() => void>(noop)
102-
103114
const sideEffect = get().sideEffect
104115

105-
useEffect(() => {
106-
savedTrickle.current = trickle
107-
})
108-
109116
useEffectOnce(() => {
110117
if (isAnimating) {
111118
start()

test/NProgress.spec.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,21 @@ test('receives render props', () => {
2222
expect(isFinished).toBe(true)
2323
expect(progress).toBe(0)
2424
})
25+
26+
test('passes animating state to children', () => {
27+
let isFinished
28+
let progress
29+
30+
render(
31+
<NProgress isAnimating>
32+
{(props) => {
33+
isFinished = props.isFinished
34+
progress = props.progress
35+
return <></>
36+
}}
37+
</NProgress>,
38+
)
39+
40+
expect(isFinished).toBe(false)
41+
expect(progress).toBe(0.1)
42+
})

test/increment.spec.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
import { increment } from '../src/increment'
22

3-
test('increments corrrectly', () => {
3+
test('increments correctly', () => {
4+
// Below zero.
45
expect(increment(-1)).toBeCloseTo(0)
6+
7+
// [0, 0.2): amount = 0.1.
58
expect(increment(0)).toBeCloseTo(0.1)
9+
expect(increment(0.19)).toBeCloseTo(0.29)
10+
11+
// [0.2, 0.5): amount = 0.04.
612
expect(increment(0.2)).toBeCloseTo(0.24)
13+
expect(increment(0.49)).toBeCloseTo(0.53)
14+
15+
// [0.5, 0.8): amount = 0.02.
716
expect(increment(0.5)).toBeCloseTo(0.52)
17+
expect(increment(0.79)).toBeCloseTo(0.81)
18+
19+
// [0.8, 0.99): amount = 0.005.
820
expect(increment(0.8)).toBeCloseTo(0.805)
21+
expect(increment(0.989)).toBeCloseTo(0.994)
22+
23+
// >= 0.99: amount = 0, clamped to 0.994.
24+
expect(increment(0.99)).toBeCloseTo(0.99)
25+
expect(increment(0.994)).toBeCloseTo(0.994)
926
expect(increment(1)).toBeCloseTo(0.994)
1027
})

test/queue.spec.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { createQueue } from '../src/createQueue'
22

3-
const { clear, enqueue } = createQueue()
4-
53
jest.useFakeTimers()
64

5+
let clear: ReturnType<typeof createQueue>['clear']
6+
let enqueue: ReturnType<typeof createQueue>['enqueue']
7+
8+
beforeEach(() => {
9+
;({ clear, enqueue } = createQueue())
10+
})
11+
712
test('starts running when the first callback is pushed onto the queue', () => {
813
const mockFn = jest.fn()
914

test/timeout.spec.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ test('executes a callback after a delay', () => {
1414
schedule(mockFn, 10)
1515
mockRaf.step()
1616
mockRaf.step()
17-
expect(mockFn).toHaveBeenCalled()
17+
expect(mockFn).toHaveBeenCalledTimes(1)
1818
})
1919

2020
test('can cancel a pending callback', () => {
@@ -25,3 +25,15 @@ test('can cancel a pending callback', () => {
2525
mockRaf.step()
2626
expect(mockFn).not.toHaveBeenCalled()
2727
})
28+
29+
test('cancels a pending callback when rescheduling', () => {
30+
const mockFn1 = jest.fn()
31+
const mockFn2 = jest.fn()
32+
schedule(mockFn1, 10)
33+
mockRaf.step()
34+
schedule(mockFn2, 10)
35+
mockRaf.step()
36+
mockRaf.step()
37+
expect(mockFn1).not.toHaveBeenCalled()
38+
expect(mockFn2).toHaveBeenCalled()
39+
})

test/useNProgress.spec.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,6 @@ test('increments correctly', () => {
5858
act(() => {
5959
mockRaf.step()
6060
mockRaf.step({ time: 201 })
61-
mockRaf.step()
62-
mockRaf.step({ time: 801 })
6361
})
6462

6563
expect(result.current).toEqual({
@@ -117,8 +115,6 @@ test('correctly restarts a finished animation', () => {
117115
act(() => {
118116
mockRaf.step()
119117
mockRaf.step({ time: 201 })
120-
mockRaf.step()
121-
mockRaf.step({ time: 801 })
122118
})
123119

124120
expect(result.current).toEqual({
@@ -129,3 +125,68 @@ test('correctly restarts a finished animation', () => {
129125

130126
unmount()
131127
})
128+
129+
test('respects custom minimum', () => {
130+
const { result, unmount } = renderHook(() =>
131+
useNProgress({ isAnimating: true, minimum: 0.3 }),
132+
)
133+
134+
// increment(0) = 0.1, clamped to minimum of 0.3.
135+
expect(result.current.progress).toBe(0.3)
136+
137+
unmount()
138+
})
139+
140+
test('respects custom animationDuration', () => {
141+
const { result, rerender, unmount } = renderHook(
142+
({ isAnimating }) => useNProgress({ animationDuration: 500, isAnimating }),
143+
{ initialProps: { isAnimating: true } },
144+
)
145+
146+
expect(result.current.animationDuration).toBe(500)
147+
148+
rerender({ isAnimating: false })
149+
150+
// Completion waits animationDuration (500ms) before isFinished.
151+
act(() => {
152+
mockRaf.step()
153+
mockRaf.step({ time: 300 })
154+
})
155+
156+
expect(result.current.isFinished).toBe(false)
157+
158+
act(() => {
159+
mockRaf.step()
160+
mockRaf.step({ time: 501 })
161+
})
162+
163+
expect(result.current.isFinished).toBe(true)
164+
165+
unmount()
166+
})
167+
168+
test('respects custom incrementDuration', () => {
169+
const { result, unmount } = renderHook(() =>
170+
useNProgress({ incrementDuration: 500, isAnimating: true }),
171+
)
172+
173+
expect(result.current.progress).toBe(0.1)
174+
175+
// Not enough time for a second trickle.
176+
act(() => {
177+
mockRaf.step()
178+
mockRaf.step({ time: 201 })
179+
})
180+
181+
expect(result.current.progress).toBe(0.1)
182+
183+
// Enough time for the second trickle.
184+
act(() => {
185+
mockRaf.step()
186+
mockRaf.step({ time: 501 })
187+
})
188+
189+
expect(result.current.progress).toBe(0.2)
190+
191+
unmount()
192+
})

0 commit comments

Comments
 (0)