diff --git a/.prettierignore b/.prettierignore index 650ea326d..dc466ec66 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ +.next README.md coverage dist diff --git a/README.md b/README.md index 182536fdf..96fd69768 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ This is a React port of [rstacruz](https://github.com/rstacruz)'s [`nprogress`](https://github.com/rstacruz/nprogress) module. It exposes an API that encapsulates the logic of `nprogress` and renders nothing, allowing consumers to implement their own rendering. +Two versions of `nprogress` are in circulation and they trickle differently. The 2014 npm release, `0.2.0`, adds a random amount of at most `0.02` every 800ms. The repository's master branch, never published to npm, adds tiered amounts every 200ms. This library mirrors master, so a side by side comparison against the npm package or the official demo page will show a different pace. The [`increment`](#increment) option covers the `0.2.0` pacing if you prefer the older feel. + ## When to Use This This package is a headless primitive. It renders no markup and ships no CSS, supplying only the pacing state: a `progress` value that trickles towards completion, an `isFinished` flag, and the `animationDuration` to transition with. The bar itself is yours to write. @@ -70,6 +72,24 @@ const Progress = ({ isAnimating }) => ( ) ``` +**Restarting** + +Both patterns leave the bar mounted between runs, and `progress` returns to `minimum` when it starts again. A bar that transitions `margin-left` or `transform` therefore animates backwards from where it finished, in full view, before it starts trickling forward. Back to back navigations hit this every time. + +Change a `key` on the bar whenever it starts, so React mounts a fresh element at `minimum` instead: + +```jsx +const [state, setState] = useState({ isAnimating: false, key: 0 }) + +const start = () => { + setState((prevState) => ({ isAnimating: true, key: prevState.key ^ 1 })) +} + +return +``` + +Every entry in [Live Examples](#live-examples) does this. Dropping the transition while `isFinished` is not an alternative: `isFinished` is already `false` by the time `progress` resets, so the transition is back on for the step that moves the bar. + ## API The package exports one hook and one component. Both take the same [options](#options) and produce the same [values](#return-value), so the choice between them is a matter of which pattern suits the calling code. Both shapes are exported as types, for typing code that wraps either entry point: @@ -85,6 +105,7 @@ Returns the state of one progress bar. Call it once per bar: two calls, or two m ```jsx const { animationDuration, isFinished, progress } = useNProgress({ animationDuration: 300, + increment: (progress) => progress + 0.01, incrementDuration: 500, isAnimating: true, minimum: 0.1, @@ -98,6 +119,7 @@ Takes the options as props and calls `children` with the values the hook returns ```jsx progress + 0.01} incrementDuration={500} isAnimating minimum={0.1} @@ -110,22 +132,43 @@ Takes the options as props and calls `children` with the values the hook returns ### Options -All four options are optional. The type is `NProgressOptions`. +All five options are optional. The type is `NProgressOptions`. -| Option | Type | Default | -| ----------------------------------------- | --------- | ------- | -| [`animationDuration`](#animationduration) | `number` | `200` | -| [`incrementDuration`](#incrementduration) | `number` | `200` | -| [`isAnimating`](#isanimating) | `boolean` | `false` | -| [`minimum`](#minimum) | `number` | `0.08` | +| Option | Type | Default | +| ----------------------------------------- | ------------------------------ | -------------- | +| [`animationDuration`](#animationduration) | `number` | `200` | +| [`increment`](#increment) | `(progress: number) => number` | tiered trickle | +| [`incrementDuration`](#incrementduration) | `number` | `200` | +| [`isAnimating`](#isanimating) | `boolean` | `false` | +| [`minimum`](#minimum) | `number` | `0.08` | #### `animationDuration` Milliseconds the bar is given to animate out once it completes. `progress` reaches `1` as soon as `isAnimating` goes `false`, and `isFinished` follows this many milliseconds later, leaving that window for the exit transition. The value is also returned unchanged, so a single number drives both the timing and the CSS transitions. +#### `increment` + +Size of each trickle step. The function is called with the current `progress` and returns the next value. The default is the tiered curve nprogress uses: `+0.1` below `0.2`, then `+0.04`, `+0.02`, and `+0.005` as `progress` grows, held at a ceiling of `0.994` so the bar never looks complete before it is. + +The return value is clamped to between `minimum` and `1`, and nothing else. A custom function therefore owns its own ceiling. Leave it short of `1`, since reaching `1` is what completion means, and let `isAnimating` going `false` take the bar the rest of the way. + +Returning a random amount is fine, but keep the function free of other side effects. It runs inside a React state update, and StrictMode calls it twice per increment in development. This trickles a random amount of at most `0.02` every 800ms, which is how nprogress `0.2.0` paces itself: + +```jsx +const { progress } = useNProgress({ + increment: (progress) => Math.min(progress + Math.random() * 0.02, 0.994), + incrementDuration: 800, + isAnimating, +}) +``` + +`0.2.0` also transitions the bar with `ease` where master uses `linear`. Easing lives in your renderer's CSS, so match it there if you want the rest of that look. The [Classic 0.2.0](https://github.com/tanem/react-nprogress/tree/master/examples/classic-020) example puts both together. + +A new function identity on every render is fine too: passing an inline function does not restart the trickle timer. The next increment uses the latest function. + #### `incrementDuration` -Milliseconds between increments while the bar is animating. It controls the trickle pacing only: the size of each increment is not configurable, and shrinks as `progress` grows. +Milliseconds between increments while the bar is animating. It controls the trickle pacing only. Step size is [`increment`](#increment). #### `isAnimating` @@ -133,7 +176,7 @@ Whether the bar is running. Going `true` starts it, going `false` completes it. #### `minimum` -Lower bound for `progress`, between `0` and `1`. The first increment starts from `0.1` rather than from `0`, so the bar appears at `max(0.1, minimum)` and the option only shows through when it is set above `0.1`. Changing it while the bar is animating does not rewind the bar. Progress holds where it is, and the new bound applies from the next increment. +Lower bound for `progress`, between `0` and `1`. The bar first appears at this value, then trickles up from there. Changing it while the bar is animating does not rewind the bar. Progress holds where it is, and the new bound applies from the next increment. ### Return Value @@ -143,12 +186,13 @@ Lower bound for `progress`, between `0` and `1`. The first increment starts from | ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `animationDuration` | `number` | The `animationDuration` option, passed through so rendering code can transition with it. | | `isFinished` | `boolean` | `true` before the bar starts and again once it has animated out. `false` from when `isAnimating` goes `true` until `animationDuration` after it goes `false`. | -| `progress` | `number` | Starts at `0` and trickles up in shrinking steps to a ceiling of `0.994`, then goes to `1` on completion. | +| `progress` | `number` | Starts at `0`, appears at `minimum` when the bar starts, then trickles up by [`increment`](#increment) and goes to `1` on completion. | ## Live Examples | Example | Sandbox | | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | +| [Classic 0.2.0](https://github.com/tanem/react-nprogress/tree/master/examples/classic-020) | [Open](https://codesandbox.io/p/devbox/github/tanem/react-nprogress/tree/master/examples/classic-020) | | [Material UI](https://github.com/tanem/react-nprogress/tree/master/examples/material-ui) | [Open](https://codesandbox.io/p/devbox/github/tanem/react-nprogress/tree/master/examples/material-ui) | | [Multiple Instances](https://github.com/tanem/react-nprogress/tree/master/examples/multiple-instances) | [Open](https://codesandbox.io/p/devbox/github/tanem/react-nprogress/tree/master/examples/multiple-instances) | | [Next App Router](https://github.com/tanem/react-nprogress/tree/master/examples/next-app-router) | [Open](https://codesandbox.io/p/devbox/github/tanem/react-nprogress/tree/master/examples/next-app-router) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 87b89564e..5ec096f63 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,7 +9,7 @@ import tseslint from 'typescript-eslint' export default tseslint.config( { - ignores: ['**/coverage/', '**/dist/', '**/node_modules/'], + ignores: ['**/.next/', '**/coverage/', '**/dist/', '**/node_modules/'], }, js.configs.recommended, ...tseslint.configs.recommended, diff --git a/examples/classic-020/.codesandbox/tasks.json b/examples/classic-020/.codesandbox/tasks.json new file mode 100644 index 000000000..4c28486ec --- /dev/null +++ b/examples/classic-020/.codesandbox/tasks.json @@ -0,0 +1,32 @@ +{ + "setupTasks": [ + { + "name": "Install Dependencies", + "command": "pnpm install" + } + ], + "tasks": { + "dev": { + "name": "dev", + "command": "pnpm dev", + "runAtStart": true, + "preview": { + "port": 5173 + } + }, + "build": { + "name": "build", + "command": "pnpm build", + "runAtStart": false + }, + "preview": { + "name": "preview", + "command": "pnpm preview", + "runAtStart": false + }, + "install": { + "name": "install dependencies", + "command": "pnpm install" + } + } +} diff --git a/examples/classic-020/.devcontainer/devcontainer.json b/examples/classic-020/.devcontainer/devcontainer.json new file mode 100644 index 000000000..3139d4259 --- /dev/null +++ b/examples/classic-020/.devcontainer/devcontainer.json @@ -0,0 +1,4 @@ +{ + "name": "Devcontainer", + "image": "ghcr.io/codesandbox/devcontainers/typescript-node:latest" +} diff --git a/examples/classic-020/.gitignore b/examples/classic-020/.gitignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/examples/classic-020/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/examples/classic-020/README.md b/examples/classic-020/README.md new file mode 100644 index 000000000..a21cbf837 --- /dev/null +++ b/examples/classic-020/README.md @@ -0,0 +1,39 @@ +# ReactNProgress Classic 0.2.0 Example + +Reproduces the pacing of nprogress `0.2.0`, the npm release the nprogress demo +page loads: the bar appears near the minimum and creeps up in small eased steps +every 800ms. + +The library defaults follow the nprogress master branch instead, which trickles +tiered amounts every 200ms with `linear` easing. The [Original +Design](../original-design) example shows that. This one changes three things +to get back to `0.2.0`. + +| nprogress `0.2.0` setting | Here | +| ------------------------- | ------------------------------------------------------------- | +| `trickleRate: 0.02` | `increment: (p) => Math.min(p + Math.random() * 0.02, 0.994)` | +| `trickleSpeed: 800` | `incrementDuration: 800` | +| `easing: 'ease'` | the bar's CSS `transition` in `src/Bar.tsx` | + +The remaining `0.2.0` settings already match the defaults: `minimum: 0.08`, and +`speed: 200`, which is `animationDuration`. The `0.994` ceiling is part of the +increment function here, because the option's return value is only clamped to +between `minimum` and `1`. + +Easing is not an option: this package renders nothing, so transitions live in +your own CSS. Only the bar position is eased in `0.2.0`. The fade-out stays +`linear`. + +## Available Scripts + +### `npm run dev` + +Runs the app in development mode. + +### `npm run build` + +Builds the app for production. + +### `npm run preview` + +Previews the production build locally. diff --git a/examples/classic-020/index.html b/examples/classic-020/index.html new file mode 100644 index 000000000..281e019df --- /dev/null +++ b/examples/classic-020/index.html @@ -0,0 +1,12 @@ + + + + + + ReactNProgress Classic 0.2.0 Example + + +
+ + + diff --git a/examples/classic-020/package.json b/examples/classic-020/package.json new file mode 100644 index 000000000..3ebdfad04 --- /dev/null +++ b/examples/classic-020/package.json @@ -0,0 +1,28 @@ +{ + "name": "classic-020", + "description": "ReactNProgress Classic 0.2.0 Example", + "keywords": [ + "@tanem/react-nprogress" + ], + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@tanem/react-nprogress": "latest", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.3" + }, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "start": "vite" + } +} diff --git a/examples/classic-020/src/Bar.tsx b/examples/classic-020/src/Bar.tsx new file mode 100644 index 000000000..6b2e370d6 --- /dev/null +++ b/examples/classic-020/src/Bar.tsx @@ -0,0 +1,37 @@ +import type { FC } from 'react' + +const Bar: FC<{ animationDuration: number; progress: number }> = ({ + animationDuration, + progress, +}) => ( +
+
+
+) + +export default Bar diff --git a/examples/classic-020/src/Container.tsx b/examples/classic-020/src/Container.tsx new file mode 100644 index 000000000..17d4bb326 --- /dev/null +++ b/examples/classic-020/src/Container.tsx @@ -0,0 +1,20 @@ +import type { FC, PropsWithChildren } from 'react' + +const Container: FC< + PropsWithChildren<{ + animationDuration: number + isFinished: boolean + }> +> = ({ animationDuration, children, isFinished }) => ( +
+ {children} +
+) + +export default Container diff --git a/examples/classic-020/src/Progress.tsx b/examples/classic-020/src/Progress.tsx new file mode 100644 index 000000000..e690595f0 --- /dev/null +++ b/examples/classic-020/src/Progress.tsx @@ -0,0 +1,29 @@ +import { useNProgress } from '@tanem/react-nprogress' +import type { FC } from 'react' + +import Bar from './Bar' +import Container from './Container' +import Spinner from './Spinner' + +// nprogress 0.2.0 trickles by `Math.random() * trickleRate`, then clamps the +// result to 0.994 so the bar never looks complete before it is. +const trickle = (progress: number) => + Math.min(progress + Math.random() * 0.02, 0.994) + +const Progress: FC<{ isAnimating: boolean }> = ({ isAnimating }) => { + const { animationDuration, isFinished, progress } = useNProgress({ + increment: trickle, + // 0.2.0's trickleSpeed. The default of 200 is the master branch's. + incrementDuration: 800, + isAnimating, + }) + + return ( + + + + + ) +} + +export default Progress diff --git a/examples/classic-020/src/Spinner.tsx b/examples/classic-020/src/Spinner.tsx new file mode 100644 index 000000000..18dddacb9 --- /dev/null +++ b/examples/classic-020/src/Spinner.tsx @@ -0,0 +1,29 @@ +import type { FC } from 'react' + +const Spinner: FC = () => ( +
+
+
+) + +export default Spinner diff --git a/examples/classic-020/src/index.css b/examples/classic-020/src/index.css new file mode 100644 index 000000000..89db1e016 --- /dev/null +++ b/examples/classic-020/src/index.css @@ -0,0 +1,8 @@ +@keyframes spinner { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/examples/classic-020/src/main.tsx b/examples/classic-020/src/main.tsx new file mode 100644 index 000000000..e261dd8e0 --- /dev/null +++ b/examples/classic-020/src/main.tsx @@ -0,0 +1,36 @@ +import './index.css' + +import { useState } from 'react' +import { createRoot } from 'react-dom/client' + +import Progress from './Progress' + +const App = () => { + const [state, setState] = useState({ + isAnimating: false, + key: 0, + }) + + return ( + <> + + + + ) +} + +const container = document.getElementById('root') +const root = createRoot(container!) +root.render() diff --git a/examples/classic-020/src/vite-env.d.ts b/examples/classic-020/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/examples/classic-020/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/classic-020/tsconfig.json b/examples/classic-020/tsconfig.json new file mode 100644 index 000000000..afb9ce332 --- /dev/null +++ b/examples/classic-020/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true + }, + "include": ["src"] +} diff --git a/examples/classic-020/vite.config.ts b/examples/classic-020/vite.config.ts new file mode 100644 index 000000000..ae745180e --- /dev/null +++ b/examples/classic-020/vite.config.ts @@ -0,0 +1,6 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/examples/material-ui/src/main.tsx b/examples/material-ui/src/main.tsx index b32844bd7..5fda390d9 100644 --- a/examples/material-ui/src/main.tsx +++ b/examples/material-ui/src/main.tsx @@ -34,6 +34,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/multiple-instances/src/main.tsx b/examples/multiple-instances/src/main.tsx index 68fb4a19a..da48e51c8 100644 --- a/examples/multiple-instances/src/main.tsx +++ b/examples/multiple-instances/src/main.tsx @@ -22,6 +22,9 @@ const A = () => { 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, })) }} @@ -49,6 +52,9 @@ const B = () => { 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/next-app-router/components/NavigationProgress.tsx b/examples/next-app-router/components/NavigationProgress.tsx index 700260454..1ab2a566e 100644 --- a/examples/next-app-router/components/NavigationProgress.tsx +++ b/examples/next-app-router/components/NavigationProgress.tsx @@ -64,6 +64,9 @@ export default function NavigationProgress({ const contextValue = useRef({ start: () => { setIsRouteChanging(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. setLoadingKey((prev) => prev ^ 1) }, }).current diff --git a/examples/next-pages-router/pages/_app.tsx b/examples/next-pages-router/pages/_app.tsx index 3796aa4a3..5867f7161 100644 --- a/examples/next-pages-router/pages/_app.tsx +++ b/examples/next-pages-router/pages/_app.tsx @@ -18,6 +18,9 @@ const App: React.FC = ({ 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() })