= ({ Component, pageProps }) => {
setState((prevState) => ({
...prevState,
isRouteChanging: true,
+ // A new key on each start remounts the bar, so it re-enters from the
+ // left rather than animating backwards from where the last navigation
+ // finished.
loadingKey: prevState.loadingKey ^ 1,
}))
}
diff --git a/examples/original-design/src/main.tsx b/examples/original-design/src/main.tsx
index 266b4f472..e261dd8e0 100644
--- a/examples/original-design/src/main.tsx
+++ b/examples/original-design/src/main.tsx
@@ -18,6 +18,9 @@ const App = () => {
onClick={() => {
setState((prevState) => ({
isAnimating: !prevState.isAnimating,
+ // A new key on each start remounts the bar, so it re-enters from
+ // the left rather than animating backwards from where the last
+ // run finished.
key: prevState.isAnimating ? prevState.key : prevState.key ^ 1,
}))
}}
diff --git a/examples/plain-js/src/main.jsx b/examples/plain-js/src/main.jsx
index 273d2186f..1baf3d778 100644
--- a/examples/plain-js/src/main.jsx
+++ b/examples/plain-js/src/main.jsx
@@ -18,6 +18,9 @@ const App = () => {
onClick={() => {
setState((prevState) => ({
isAnimating: !prevState.isAnimating,
+ // A new key on each start remounts the bar, so it re-enters from
+ // the left rather than animating backwards from where the last
+ // run finished.
key: prevState.isAnimating ? prevState.key : prevState.key ^ 1,
}))
}}
diff --git a/examples/react-router/src/main.tsx b/examples/react-router/src/main.tsx
index 24d8a3d02..b07e35ffb 100644
--- a/examples/react-router/src/main.tsx
+++ b/examples/react-router/src/main.tsx
@@ -169,6 +169,9 @@ const Home = () => {
A key change creates a new NProgress instance, resetting progress
when the location changes. See:
https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#recommendation-fully-uncontrolled-component-with-a-key.
+ Remounting is also what makes the bar re-enter from the left on the
+ next navigation, rather than animating backwards from where the last
+ one finished.
*/}
diff --git a/examples/render-props/src/main.tsx b/examples/render-props/src/main.tsx
index 266b4f472..e261dd8e0 100644
--- a/examples/render-props/src/main.tsx
+++ b/examples/render-props/src/main.tsx
@@ -18,6 +18,9 @@ const App = () => {
onClick={() => {
setState((prevState) => ({
isAnimating: !prevState.isAnimating,
+ // A new key on each start remounts the bar, so it re-enters from
+ // the left rather than animating backwards from where the last
+ // run finished.
key: prevState.isAnimating ? prevState.key : prevState.key ^ 1,
}))
}}
diff --git a/src/types.ts b/src/types.ts
index 0df795d07..4cd8b2239 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,6 @@
export interface NProgressOptions {
animationDuration?: number
+ increment?: (progress: number) => number
incrementDuration?: number
isAnimating?: boolean
minimum?: number
diff --git a/src/useNProgress.tsx b/src/useNProgress.tsx
index e19f55d04..eb76fb88b 100644
--- a/src/useNProgress.tsx
+++ b/src/useNProgress.tsx
@@ -1,8 +1,8 @@
-import { useEffect, useReducer } from 'react'
+import { useEffect, useReducer, useRef } from 'react'
import { clamp } from './clamp'
import { createTimeout } from './createTimeout'
-import { increment } from './increment'
+import { increment as defaultIncrement } from './increment'
import type { NProgressOptions, NProgressState } from './types'
// A four-phase state machine. `idle` and `finished` both report
@@ -16,8 +16,12 @@ interface State {
}
type Action =
+ | {
+ increment: (progress: number) => number
+ minimum: number
+ type: 'trickle'
+ }
| { minimum: number; type: 'start' }
- | { minimum: number; type: 'trickle' }
| { type: 'complete' }
| { type: 'finish' }
@@ -29,10 +33,10 @@ const initialState: State = {
const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'complete':
- // 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.
+ // The original nprogress `done()` computes a random progress jump before
+ // animating to 1, but its queue runs both steps in the same tick, so the
+ // jump's CSS is overwritten before paint and never renders. Omitting the
+ // jump changes nothing visually.
//
// Ignored unless an animation is actually running, which is what makes a
// StrictMode double-mount a no-op rather than a spurious completion.
@@ -44,11 +48,8 @@ const reducer = (state: State, action: Action): State => {
return { phase: 'finished', progress: 1 }
case 'start':
- // 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.
+ // Matches the original nprogress `start()`, which calls set(0) and so
+ // paints first at `minimum` before any trickle runs.
//
// Guarded the same way as `complete`, and for the same reason: a repeat
// dispatch against a running animation must not rewind the bar. That
@@ -58,25 +59,40 @@ const reducer = (state: State, action: Action): State => {
? state
: {
phase: 'animating',
- progress: clamp(increment(0), action.minimum, 1),
+ progress: clamp(0, action.minimum, 1),
}
case 'trickle':
+ // Clamped here rather than trusted from the increment function, so a
+ // custom one cannot take the bar outside the documented range. Stopping
+ // short of 1 is that function's own business: 1 is what completion
+ // means.
return {
...state,
- progress: clamp(increment(state.progress), action.minimum, 1),
+ progress: clamp(action.increment(state.progress), action.minimum, 1),
}
}
}
export const useNProgress = ({
animationDuration = 200,
+ increment = defaultIncrement,
incrementDuration = 200,
isAnimating = false,
minimum = 0.08,
}: NProgressOptions = {}): NProgressState => {
const [{ phase, progress }, dispatch] = useReducer(reducer, initialState)
+ // Held in a ref so the trickle timer can read the latest increment function
+ // without listing it as a dependency. Consumers commonly pass an inline
+ // function, whose identity changes every render; depending on it directly
+ // would cancel and recreate the timer each time, and a render loop faster
+ // than `incrementDuration` would stop the bar advancing altogether.
+ const incrementRef = useRef(increment)
+ useEffect(() => {
+ incrementRef.current = increment
+ })
+
useEffect(() => {
dispatch(isAnimating ? { minimum, type: 'start' } : { type: 'complete' })
}, [isAnimating, minimum])
@@ -94,7 +110,7 @@ export const useNProgress = ({
const timeout = createTimeout()
const trickle = () => {
- dispatch({ minimum, type: 'trickle' })
+ dispatch({ increment: incrementRef.current, minimum, type: 'trickle' })
timeout.schedule(trickle, incrementDuration)
}
timeout.schedule(trickle, incrementDuration)
diff --git a/test/NProgress.spec.tsx b/test/NProgress.spec.tsx
index 997178539..65f1cf554 100644
--- a/test/NProgress.spec.tsx
+++ b/test/NProgress.spec.tsx
@@ -38,5 +38,5 @@ test('passes animating state to children', () => {
)
expect(isFinished).toBe(false)
- expect(progress).toBe(0.1)
+ expect(progress).toBe(0.08)
})
diff --git a/test/useNProgress.spec.ts b/test/useNProgress.spec.ts
index f4a3bbd56..f66d95ed2 100644
--- a/test/useNProgress.spec.ts
+++ b/test/useNProgress.spec.ts
@@ -35,7 +35,7 @@ test('starts animating when isAnimating is true', () => {
expect(result.current).toEqual({
animationDuration: 200,
isFinished: false,
- progress: 0.1,
+ progress: 0.08,
})
unmount()
@@ -52,7 +52,7 @@ test('starts animating when isAnimating changes from false to true', () => {
expect(result.current).toEqual({
animationDuration: 200,
isFinished: false,
- progress: 0.1,
+ progress: 0.08,
})
unmount()
@@ -71,7 +71,7 @@ test('increments correctly', () => {
expect(result.current).toEqual({
animationDuration: 200,
isFinished: false,
- progress: 0.2,
+ progress: 0.18,
})
unmount()
@@ -128,7 +128,7 @@ test('correctly restarts a finished animation', () => {
expect(result.current).toEqual({
animationDuration: 200,
isFinished: false,
- progress: 0.2,
+ progress: 0.18,
})
unmount()
@@ -139,7 +139,7 @@ test('respects custom minimum', () => {
useNProgress({ isAnimating: true, minimum: 0.3 }),
)
- // increment(0) = 0.1, clamped to minimum of 0.3.
+ // The start value of 0 is clamped up to the minimum of 0.3.
expect(result.current.progress).toBe(0.3)
unmount()
@@ -211,11 +211,11 @@ test('starts and trickles once under StrictMode', () => {
expect(result.current).toEqual({
animationDuration: 200,
isFinished: false,
- progress: 0.1,
+ progress: 0.08,
})
// A duplicate timer from the dev double-mount would trickle twice here,
- // taking progress to increment(0.2) = 0.24.
+ // taking progress to increment(0.18) = 0.28.
act(() => {
mockRaf.step()
mockRaf.step({ time: 201 })
@@ -224,7 +224,7 @@ test('starts and trickles once under StrictMode', () => {
expect(result.current).toEqual({
animationDuration: 200,
isFinished: false,
- progress: 0.2,
+ progress: 0.18,
})
unmount()
@@ -279,7 +279,7 @@ test('respects custom incrementDuration', () => {
useNProgress({ incrementDuration: 500, isAnimating: true }),
)
- expect(result.current.progress).toBe(0.1)
+ expect(result.current.progress).toBe(0.08)
// Not enough time for a second trickle.
act(() => {
@@ -287,7 +287,7 @@ test('respects custom incrementDuration', () => {
mockRaf.step({ time: 201 })
})
- expect(result.current.progress).toBe(0.1)
+ expect(result.current.progress).toBe(0.08)
// Enough time for the second trickle.
act(() => {
@@ -295,7 +295,7 @@ test('respects custom incrementDuration', () => {
mockRaf.step({ time: 501 })
})
- expect(result.current.progress).toBe(0.2)
+ expect(result.current.progress).toBe(0.18)
unmount()
})
@@ -314,13 +314,13 @@ test('keeps its place when minimum changes mid-animation', () => {
mockRaf.step({ time: 201 })
})
- expect(result.current.progress).toBe(0.2)
+ expect(result.current.progress).toBe(0.18)
rerender({ minimum: 0.09 })
// A `start` dispatch against a running animation is ignored, so progress
- // holds rather than dropping back to increment(0).
- expect(result.current.progress).toBe(0.2)
+ // holds rather than dropping back to the minimum.
+ expect(result.current.progress).toBe(0.18)
unmount()
})
@@ -337,7 +337,7 @@ test('keeps trickling when animationDuration changes mid-animation', () => {
mockRaf.step({ time: 201 })
})
- expect(result.current.progress).toBe(0.2)
+ expect(result.current.progress).toBe(0.18)
// The animating phase does not read `animationDuration`, so changing it just
// before the next trickle is due must not cancel the pending timer.
@@ -349,10 +349,105 @@ test('keeps trickling when animationDuration changes mid-animation', () => {
mockRaf.step({ time: 401 })
})
- // increment(0.2) is 0.24 in decimal but 0.24000000000000002 in binary
- // floating point, so this is the one assertion in the file that cannot use
- // an exact match.
- expect(result.current.progress).toBeCloseTo(0.24, 10)
+ expect(result.current.progress).toBe(0.28)
+
+ unmount()
+})
+
+test('respects a custom increment', () => {
+ const { result, unmount } = renderHook(() =>
+ useNProgress({
+ increment: (progress) => progress + 0.25,
+ isAnimating: true,
+ }),
+ )
+
+ expect(result.current.progress).toBe(0.08)
+
+ act(() => {
+ mockRaf.step()
+ mockRaf.step({ time: 201 })
+ })
+
+ expect(result.current.progress).toBe(0.33)
+
+ unmount()
+})
+
+test('clamps a custom increment to the documented range', () => {
+ const { result, rerender, unmount } = renderHook(
+ ({ increment }) => useNProgress({ increment, isAnimating: true }),
+ { initialProps: { increment: () => 5 } },
+ )
+
+ act(() => {
+ mockRaf.step()
+ mockRaf.step({ time: 201 })
+ })
+
+ expect(result.current.progress).toBe(1)
+
+ rerender({ increment: () => -5 })
+
+ act(() => {
+ mockRaf.step()
+ mockRaf.step({ time: 401 })
+ })
+
+ expect(result.current.progress).toBe(0.08)
+
+ unmount()
+})
+
+test('uses the latest increment on the next trickle', () => {
+ const { result, rerender, unmount } = renderHook(
+ ({ increment }) => useNProgress({ increment, isAnimating: true }),
+ { initialProps: { increment: (progress: number) => progress + 0.1 } },
+ )
+
+ act(() => {
+ mockRaf.step()
+ mockRaf.step({ time: 201 })
+ })
+
+ expect(result.current.progress).toBe(0.18)
+
+ rerender({ increment: (progress: number) => progress + 0.25 })
+
+ act(() => {
+ mockRaf.step()
+ mockRaf.step({ time: 401 })
+ })
+
+ expect(result.current.progress).toBe(0.43)
+
+ unmount()
+})
+
+// Consumers commonly pass an inline function, so a new identity arrives on
+// every render. That must not cancel the pending trickle timer.
+test('keeps trickling when a new increment identity arrives mid-animation', () => {
+ const { result, rerender, unmount } = renderHook(
+ ({ increment }) => useNProgress({ increment, isAnimating: true }),
+ { initialProps: { increment: (progress: number) => progress + 0.1 } },
+ )
+
+ act(() => {
+ mockRaf.step()
+ mockRaf.step({ time: 201 })
+ })
+
+ expect(result.current.progress).toBe(0.18)
+
+ act(() => {
+ mockRaf.step({ time: 399 })
+ })
+ rerender({ increment: (progress: number) => progress + 0.1 })
+ act(() => {
+ mockRaf.step({ time: 401 })
+ })
+
+ expect(result.current.progress).toBe(0.28)
unmount()
})