Skip to content

Instantly share code, notes, and snippets.

@tkrotoff
Last active May 14, 2023 22:55
Show Gist options
  • Save tkrotoff/db8a8106cc93ae797ea968d78ea28047 to your computer and use it in GitHub Desktop.
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)
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;
}
// 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);
}
// 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;
}
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} />
</>
);
}
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);
});
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);
});
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
};
}
// 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