Last active
May 14, 2023 22:55
-
-
Save tkrotoff/db8a8106cc93ae797ea968d78ea28047 to your computer and use it in GitHub Desktop.
Progress bar like NProgress in 90 lines of code (vs NProgress v0.2.0 is 470 lines .js + 70 lines .css)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useRouter } from 'next/router'; | |
import { useEffect } from 'react'; | |
import { useProgressBar } from './useProgressBar'; | |
// https://github.com/twbs/bootstrap/blob/v5.3.0-alpha1/scss/_variables.scss#L1529 | |
const transitionSpeed = 600; | |
// https://gist.github.com/tkrotoff/db8a8106cc93ae797ea968d78ea28047 | |
// https://stackoverflow.com/q/60755316 | |
// https://stackoverflow.com/q/55624695 | |
export function RouterProgressBar(props?: Parameters<typeof useProgressBar>[0]) { | |
const { events } = useRouter(); | |
const { width, start, complete, reset } = useProgressBar({ transitionSpeed, ...props }); | |
useEffect(() => { | |
events.on('routeChangeStart', start); | |
events.on('routeChangeComplete', complete); | |
events.on('routeChangeError', reset); // Typical case: "Route Cancelled" | |
return () => { | |
events.off('routeChangeStart', start); | |
events.off('routeChangeComplete', complete); | |
events.off('routeChangeError', reset); | |
}; | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, []); | |
return width > 0 ? ( | |
<div | |
className="progress fixed-top bg-transparent rounded-0" | |
style={{ | |
height: 3, // GitHub turbo-progress-bar height is 3px | |
zIndex: 1091 // $zindex-toast + 1 => always visible | |
}} | |
> | |
<div | |
className="progress-bar" | |
style={{ | |
width: `${width}%`, | |
//transition: 'none', | |
// https://github.com/twbs/bootstrap/blob/v5.3.0-alpha1/scss/_variables.scss#L1529 | |
transition: `width ${ | |
// Why transition is 0 if width < 1% ? | |
// If a `complete()` (width 100%) has been aborted (by a `start()`), | |
// the slow CSS transition will prevent the progress bar from visually reaching width 1% | |
// Without this hack (i.e. forcing the stop of the previous CSS transition), | |
// the progress bar would temporary look like K 2000 (Knight Rider) car front-mounted scanner | |
width > 1 ? transitionSpeed : 0 | |
}ms ease` | |
}} | |
/> | |
</div> | |
) : null; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// https://gist.github.com/tkrotoff/1a216f376cb4fba5bc7d8b5109c3a32e | |
// https://devblogs.microsoft.com/typescript/announcing-typescript-3-7/#assertion-functions | |
export function assert(_condition: boolean, _message?: string): asserts _condition { | |
// eslint-disable-next-line no-console, prefer-rest-params | |
console.assert(...arguments); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// https://gist.github.com/tkrotoff/f3f36926edeeb3f4ce4411151bde37b2 | |
// Exported for testing purposes only | |
// https://stackoverflow.com/a/45736131 | |
export function getNumberWithDecimalPlaces(num: number, decimalPlaces: number) { | |
const power = 10 ** decimalPlaces; | |
return Math.floor(num * power) / power; | |
} | |
type GetRandomNumberOptions = { | |
/** | |
* The number of digits to appear after the decimal point. | |
* https://ell.stackexchange.com/q/141863 | |
*/ | |
decimalPlaces?: number; | |
}; | |
// min included, max excluded | |
export function getRandomFloat(min: number, max: number, options: GetRandomNumberOptions = {}) { | |
const { decimalPlaces } = options; | |
const num = Math.random() * (max - min) + min; | |
if (decimalPlaces === undefined) { | |
return num; | |
} | |
return getNumberWithDecimalPlaces(num, decimalPlaces); | |
} | |
// min/max included | |
export function getRandomInt(min: number, max: number) { | |
// https://stackoverflow.com/a/7228322 | |
return Math.floor(Math.random() * (max - min + 1)) + min; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { AppProps } from 'next/app'; | |
import Head from 'next/head'; | |
import { RouterProgressBar } from './RouterProgressBar'; | |
export default function App({ Component, pageProps }: AppProps) { | |
return ( | |
<> | |
<Head> | |
<title>My title</title> | |
<meta name="description" content="My description" /> | |
</Head> | |
<RouterProgressBar /> | |
<Component {...pageProps} /> | |
</> | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { act, render } from '@testing-library/react'; | |
import { useRouter } from 'next/router'; | |
import { EventEmitter } from 'node:events'; | |
import { wait } from './wait'; | |
import { RouterProgressBar } from './RouterProgressBar'; | |
jest.mock('next/router', () => ({ | |
useRouter: jest.fn() | |
})); | |
//const useRouterMock = jest.mocked(useRouter); | |
const useRouterMock = useRouter as jest.Mock; | |
const events = new EventEmitter(); | |
useRouterMock.mockReturnValue({ events }); | |
const trickleMaxWidth = 15; | |
const trickleIncrement = 1; | |
const dropSpeed = 60; // Cannot be lower or tests may fail | |
const transitionSpeed = dropSpeed * 2; | |
function renderRouterProgressBar() { | |
return render( | |
<RouterProgressBar | |
trickleMaxWidth={trickleMaxWidth} | |
trickleIncrementMin={trickleIncrement} | |
trickleIncrementMax={trickleIncrement} | |
dropMinSpeed={dropSpeed} | |
dropMaxSpeed={dropSpeed} | |
transitionSpeed={transitionSpeed} | |
/> | |
); | |
} | |
function widthCSSRegex(width: number | string) { | |
// HTML generated: | |
// <div class="progress fixed-top bg-transparent rounded-0" style="..."> | |
// <div class="progress-bar" style="width: ${width}%; ..."></div> | |
// </div> | |
return new RegExp(` style="width: ${width}%;`); | |
} | |
async function emitRouteChangeStartAndWait3Drops() { | |
act(() => events.emit('routeChangeStart')); | |
// eslint-disable-next-line testing-library/no-unnecessary-act | |
await act(() => wait(dropSpeed * 3)); | |
} | |
test('render start', async () => { | |
const { container, unmount } = renderRouterProgressBar(); | |
expect(container).toBeEmptyDOMElement(); | |
act(() => events.emit('routeChangeStart')); | |
expect(container.innerHTML).toMatch(widthCSSRegex(1)); | |
// eslint-disable-next-line testing-library/no-unnecessary-act | |
await act(() => wait(dropSpeed * 3)); | |
expect(container.innerHTML).toMatch(widthCSSRegex(1 + trickleIncrement * 3)); | |
unmount(); | |
expect(container).toBeEmptyDOMElement(); | |
}); | |
test('start stops at trickleMaxWidth%', async () => { | |
const { container } = renderRouterProgressBar(); | |
act(() => events.emit('routeChangeStart')); | |
// eslint-disable-next-line testing-library/no-unnecessary-act | |
await act(() => wait(dropSpeed * 20)); | |
expect(container.innerHTML).toMatch(widthCSSRegex(trickleMaxWidth)); | |
}); | |
test('default props', async () => { | |
render(<RouterProgressBar />); | |
act(() => events.emit('routeChangeStart')); | |
}); | |
test('override 2 props', async () => { | |
const { container } = render( | |
<RouterProgressBar trickleMaxWidth={5} transitionSpeed={transitionSpeed} /> | |
); | |
act(() => events.emit('routeChangeStart')); | |
// eslint-disable-next-line testing-library/no-unnecessary-act | |
await act(() => wait(dropSpeed * 10)); | |
expect(container.innerHTML).toMatch(widthCSSRegex('\\d')); // Range 0 to 9% | |
}); | |
test('render complete', async () => { | |
const { container } = renderRouterProgressBar(); | |
expect(container).toBeEmptyDOMElement(); | |
act(() => events.emit('routeChangeStart')); | |
act(() => events.emit('routeChangeComplete')); | |
expect(container.innerHTML).toMatch(widthCSSRegex(100)); | |
// eslint-disable-next-line testing-library/no-unnecessary-act | |
await act(() => wait(transitionSpeed + 10)); | |
expect(container).toBeEmptyDOMElement(); | |
}); | |
test('abort render complete', async () => { | |
const { container } = renderRouterProgressBar(); | |
expect(container).toBeEmptyDOMElement(); | |
act(() => events.emit('routeChangeStart')); | |
act(() => events.emit('routeChangeComplete')); | |
expect(container.innerHTML).toMatch(widthCSSRegex(100)); | |
// Abort complete | |
act(() => events.emit('routeChangeStart')); | |
expect(container.innerHTML).toMatch(widthCSSRegex(1)); | |
}); | |
test('render error', async () => { | |
const { container } = renderRouterProgressBar(); | |
expect(container).toBeEmptyDOMElement(); | |
// Abort current trickle | |
act(() => events.emit('routeChangeError')); | |
expect(container).toBeEmptyDOMElement(); | |
}); | |
test('abort when unmount', async () => { | |
const { container, unmount } = renderRouterProgressBar(); | |
expect(container).toBeEmptyDOMElement(); | |
await emitRouteChangeStartAndWait3Drops(); | |
unmount(); | |
await wait(dropSpeed * 3); | |
// Cannot verify trickle has been aborted :-/ | |
// See useProgressBar.test.ts | |
}); | |
test('remove listeners', async () => { | |
expect(events.listenerCount('routeChangeStart')).toEqual(0); | |
expect(events.listenerCount('routeChangeComplete')).toEqual(0); | |
expect(events.listenerCount('routeChangeError')).toEqual(0); | |
const { unmount } = renderRouterProgressBar(); | |
expect(events.listenerCount('routeChangeStart')).toEqual(1); | |
expect(events.listenerCount('routeChangeComplete')).toEqual(1); | |
expect(events.listenerCount('routeChangeError')).toEqual(1); | |
await emitRouteChangeStartAndWait3Drops(); | |
expect(events.listenerCount('routeChangeStart')).toEqual(1); | |
expect(events.listenerCount('routeChangeComplete')).toEqual(1); | |
expect(events.listenerCount('routeChangeError')).toEqual(1); | |
unmount(); | |
expect(events.listenerCount('routeChangeStart')).toEqual(0); | |
expect(events.listenerCount('routeChangeComplete')).toEqual(0); | |
expect(events.listenerCount('routeChangeError')).toEqual(0); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { act, renderHook, waitFor } from '@testing-library/react'; | |
import { wait } from './wait'; | |
import { useProgressBar } from './useProgressBar'; | |
const trickleMaxWidth = 15; | |
const dropSpeed = 60; // Cannot be lower or tests may fail | |
const transitionSpeed = dropSpeed * 2; | |
function renderUseProgressBar() { | |
return renderHook(() => | |
useProgressBar({ | |
trickleMaxWidth, | |
trickleIncrementMin: 1, | |
trickleIncrementMax: 1, | |
dropMinSpeed: dropSpeed, | |
dropMaxSpeed: dropSpeed, | |
transitionSpeed | |
}) | |
); | |
} | |
async function startAndWaitWidth3(result: { current: ReturnType<typeof useProgressBar> }) { | |
act(() => { | |
result.current.start(); | |
}); | |
await waitFor(() => expect(result.current.width).toEqual(3)); | |
} | |
test('start', async () => { | |
const { result } = renderUseProgressBar(); | |
expect(result.current.width).toEqual(0); | |
act(() => { | |
result.current.start(); | |
}); | |
expect(result.current.width).toEqual(1); | |
await waitFor(() => expect(result.current.width).toEqual(3)); | |
}); | |
test('start stops at trickleMaxWidth%', async () => { | |
const { result } = renderUseProgressBar(); | |
await act(() => result.current.start()); | |
expect(result.current.width).toEqual(trickleMaxWidth); | |
}); | |
test('default options', async () => { | |
const { result } = renderHook(() => useProgressBar()); | |
act(() => { | |
result.current.start(); | |
}); | |
await waitFor(() => expect(result.current.width).toBeGreaterThanOrEqual(3)); | |
}); | |
test('override 1 option', async () => { | |
const { result } = renderHook(() => useProgressBar({ trickleMaxWidth: 10 })); | |
await act(() => result.current.start()); | |
expect(result.current.width).toBeGreaterThanOrEqual(10); | |
expect(result.current.width).toBeLessThan(20); | |
}); | |
test('complete', async () => { | |
const { result } = renderUseProgressBar(); | |
await startAndWaitWidth3(result); | |
act(() => { | |
result.current.complete(); | |
}); | |
await waitFor(() => expect(result.current.width).toEqual(100)); | |
// eslint-disable-next-line testing-library/no-unnecessary-act | |
await act(() => wait(transitionSpeed + 1)); | |
expect(result.current.width).toEqual(0); | |
}); | |
test('abort complete', async () => { | |
const { result } = renderUseProgressBar(); | |
await startAndWaitWidth3(result); | |
act(() => { | |
result.current.complete(); | |
}); | |
await waitFor(() => expect(result.current.width).toEqual(100)); | |
// Abort complete | |
await startAndWaitWidth3(result); | |
}); | |
test('reset', async () => { | |
const { result } = renderUseProgressBar(); | |
await startAndWaitWidth3(result); | |
// Abort current trickle + set width to 0 | |
act(() => { | |
result.current.reset(); | |
}); | |
await waitFor(() => expect(result.current.width).toEqual(0)); | |
}); | |
test('abort when unmount', async () => { | |
const { result, unmount } = renderUseProgressBar(); | |
await startAndWaitWidth3(result); | |
// Abort current trickle == width should not change anymore | |
unmount(); | |
await wait(dropSpeed); | |
expect(result.current.width).toEqual(3); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useEffect, useReducer, useRef } from 'react'; | |
import { assert } from './assert'; | |
import { wait } from './wait'; | |
import { getRandomInt } from './getRandomNumber'; | |
let waitController: AbortController | undefined; | |
// https://gist.github.com/tkrotoff/db8a8106cc93ae797ea968d78ea28047 | |
// https://stackoverflow.com/q/60755316 | |
// https://stackoverflow.com/q/55624695 | |
export function useProgressBar({ | |
trickleMaxWidth = 94, | |
trickleIncrementMin = 1, | |
trickleIncrementMax = 5, | |
dropMinSpeed = 50, | |
dropMaxSpeed = 150, | |
// https://github.com/twbs/bootstrap/blob/v5.3.0-alpha1/scss/_variables.scss#L1529 | |
transitionSpeed = 600 | |
} = {}) { | |
// https://stackoverflow.com/a/66436476 | |
const [, forceUpdate] = useReducer(x => x + 1, 0); | |
// https://github.com/facebook/react/issues/14010#issuecomment-433788147 | |
const widthRef = useRef(0); | |
function setWidth(value: number) { | |
widthRef.current = value; | |
forceUpdate(); | |
} | |
async function trickle() { | |
if (widthRef.current < trickleMaxWidth) { | |
const inc = widthRef.current + getRandomInt(trickleIncrementMin, trickleIncrementMax); // ~3 | |
setWidth(inc); | |
try { | |
await wait(getRandomInt(dropMinSpeed, dropMaxSpeed) /* ~100 ms */, { | |
signal: waitController!.signal | |
}); | |
await trickle(); | |
} catch { | |
// Current loop aborted: a new route has been started | |
} | |
} | |
} | |
async function start() { | |
// Abort current loops if any: a new route has been started | |
waitController?.abort(); | |
waitController = new AbortController(); | |
// Force the show the JSX | |
setWidth(1); | |
await wait(0); | |
await trickle(); | |
} | |
async function complete() { | |
assert( | |
waitController !== undefined, | |
'Make sure start() is called before calling complete()' | |
); | |
setWidth(100); | |
try { | |
await wait(transitionSpeed, { signal: waitController.signal }); | |
setWidth(0); | |
} catch { | |
// Current loop aborted: a new route has been started | |
} | |
} | |
function reset() { | |
// Abort current loops if any | |
waitController?.abort(); | |
setWidth(0); | |
} | |
useEffect(() => { | |
return () => { | |
// Abort current loops if any | |
waitController?.abort(); | |
}; | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
}, []); | |
return { | |
start, | |
complete, | |
reset, | |
width: widthRef.current | |
}; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// https://gist.github.com/tkrotoff/c6dd1cabf5570906724097c6e3f65a12 | |
// https://stackoverflow.com/a/67911932 | |
export function wait(ms: number, options: { signal?: AbortSignal } = {}) { | |
const { signal } = options; | |
return new Promise<void>((resolve, reject) => { | |
// FIXME Not supported by Jest 29.3.1 + Node.js 19.3.0 | |
//signal?.throwIfAborted(); | |
if (signal?.aborted) reject(signal.reason); | |
const id = setTimeout(() => { | |
resolve(); | |
// eslint-disable-next-line @typescript-eslint/no-use-before-define | |
signal?.removeEventListener('abort', abort); | |
}, ms); | |
function abort() { | |
clearTimeout(id); | |
reject(signal!.reason); | |
} | |
signal?.addEventListener('abort', abort); | |
}); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment