Skip to content

Commit b5b082c

Browse files
authored
feat: new hook, useNetworkState (#35)
* feat: new hook, useNetworkState * fix: add MDN link to the docs page. * feat: add tests for on and off helpers. * feat: improve useNetworkState tests
1 parent 21347a6 commit b5b082c

10 files changed

+355
-22
lines changed

CONTRIBUTING.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ first.
1515
4. Install dependencies: `yarn`
1616
5. Make sure everything builds and tests: `yarn build && yarn test`
1717
6. Create the branch for your PR, like: `git checkout -b pr/my-awesome-hook`
18-
- in case you are adding a new hook - it is better to name your branch by the hook: `pr/useUpdateEffect`
18+
- in case you are adding a new hook - it is better to name your branch by the
19+
hook: `pr/useUpdateEffect`
1920
- in case your change fixes an issue - it is better to name branch by the issue
2021
id: `pr/fix-12345`
2122
7. Follow the directions below
@@ -40,14 +41,14 @@ first.
4041
- In case hook has some custom types as arguments or return values - it should also be exported.
4142
- All types and interfaces should be `I` prefixed.
4243
- Hook should be developed with SSR in mind.
43-
- In case hook is stateful and exposes `setState` method it should use `useSafeState` instead of
44-
`useState`, since `useSafeState`.
44+
- In case hook is stateful and exposes `setState` method, or is has async callbacks (that can
45+
resolve aster component unmount), it should use `useSafeState` instead of `useState`.
4546
2. Reexport hook implementation and all custom types in `src/index.ts`.
4647
3. Write complete tests for your hook, tests should consist of both DOM and SSR parts.
4748
- Hook's test should be placed in `tests` folder and named after the hook.
4849
4ex: `test/dom/useFirstMountState.test.ts` and `test/ssr/useFirstMountState.test.ts`.
49-
- Ideally your hook should have 100% test coverage. For cases where that is impossible,
50-
you should comment above the code exactly why it is impossible to have 100% coverage.
50+
- Ideally your hook should have 100% test coverage. For cases where that is impossible, you
51+
should comment above the code exactly why it is impossible to have 100% coverage.
5152
- Each hook should have at least 'should be defined' and 'should render' tests in `SSR`
5253
environment.
5354
- All utility functions should also be tested.
@@ -59,9 +60,12 @@ first.
5960
- Components representing hook functionality should be placed in file named after the hook
6061
with `.stories` suffix.
6162
4ex: `useFirstMountState.stories.tsx`.
62-
- Preferred format to write the docs is MDX. [Read more about storybook docs](https://storybook.js.org/docs/react/writing-docs/introduction).
63+
- Preferred format to write the docs is
64+
MDX. [Read more about storybook docs](https://storybook.js.org/docs/react/writing-docs/introduction)
65+
.
6366
5. Add docs link and hook summary to the `README.md`.
64-
6. After all above steps are done - run `yarn lint:fix` and ensure that everything is styled by our standards.
67+
6. After all above steps are done - run `yarn lint:fix` and ensure that everything is styled by our
68+
standards.
6569
6670
## Committing
6771

README.md

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,30 +51,35 @@ import { useMountEffect } from "@react-hookz/web/esnext";
5151
## Hooks list
5252

5353
- #### Lifecycle
54-
- [`useConditionalEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionaleffect)
54+
55+
- [`useConditionalEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionaleffect--example)
5556
— Like `useEffect` but callback invoked only if conditions match predicate.
56-
- [`useConditionalUpdateEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionalupdateeffect)
57+
- [`useConditionalUpdateEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionalupdateeffect--example)
5758
— Like `useUpdateEffect` but callback invoked only if conditions match predicate.
58-
- [`useFirstMountState`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usefirstmountstate)
59+
- [`useFirstMountState`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usefirstmountstate--example)
5960
— Return boolean that is `true` only on first render.
60-
- [`useIsMounted`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useismounted)
61+
- [`useIsMounted`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useismounted--example)
6162
— Returns function that yields current mount state.
62-
- [`useMountEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usemounteffect)
63+
- [`useMountEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usemounteffect--example)
6364
— Run effect only when component is first mounted.
64-
- [`useRerender`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usererender)
65+
- [`useRerender`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usererender--example)
6566
— Return callback that re-renders component.
66-
- [`useUnmountEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useunmounteffect)
67+
- [`useUnmountEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useunmounteffect--example)
6768
— Run effect only when component is unmounted.
68-
- [`useUpdateEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useupdateeffect)
69+
- [`useUpdateEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useupdateeffect--example)
6970
— Effect hook that ignores the first render (not invoked on mount).
7071

7172
- #### State
72-
- [`useMediatedState`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usemediatedstate)
73+
74+
- [`useMediatedState`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usemediatedstate--example)
7375
— Like `useState`, but every value set is passed through a mediator function.
74-
- [`usePrevious`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useprevious)
76+
- [`usePrevious`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useprevious--example)
7577
— Returns the value passed to the hook on previous render.
76-
- [`useSafeState`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usesafestate)
78+
- [`useSafeState`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usesafestate--example)
7779
— Like `useState`, but its state setter is guarded against sets on unmounted component.
78-
- [`useToggle`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usetoggle)
80+
- [`useToggle`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usetoggle--example)
7981
— Like `useState`, but can only become `true` or `false`.
8082

83+
- ### Sensor
84+
- [`useNetworkState`](http://localhost:6006/?path=/docs/sensor-usenetwork--example)
85+
— Tracks the state of browser's network connection.

src/useNetworkState.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { useEffect } from 'react';
2+
import { isBrowser, noop } from './util/const';
3+
import { IInitialState } from './util/resolveHookState';
4+
import { useSafeState } from './useSafeState';
5+
import { off, on } from './util/misc';
6+
7+
export interface INetworkInformation extends EventTarget {
8+
readonly downlink: number;
9+
readonly downlinkMax: number;
10+
readonly effectiveType: 'slow-2g' | '2g' | '3g' | '4g';
11+
readonly rtt: number;
12+
readonly saveData: boolean;
13+
readonly type:
14+
| 'bluetooth'
15+
| 'cellular'
16+
| 'ethernet'
17+
| 'none'
18+
| 'wifi'
19+
| 'wimax'
20+
| 'other'
21+
| 'unknown';
22+
}
23+
24+
export interface IUseNetworkState {
25+
/**
26+
* @desc Whether browser connected to the network or not.
27+
*/
28+
online: boolean | undefined;
29+
/**
30+
* @desc Previous value of `online` property. Helps to identify if browser
31+
* just connected or lost connection.
32+
*/
33+
previous: boolean | undefined;
34+
/**
35+
* @desc The {Date} object pointing to the moment when state change occurred.
36+
*/
37+
since: Date | undefined;
38+
/**
39+
* @desc Effective bandwidth estimate in megabits per second, rounded to the
40+
* nearest multiple of 25 kilobits per seconds.
41+
*/
42+
downlink: INetworkInformation['downlink'] | undefined;
43+
/**
44+
* @desc Maximum downlink speed, in megabits per second (Mbps), for the
45+
* underlying connection technology
46+
*/
47+
downlinkMax: INetworkInformation['downlinkMax'] | undefined;
48+
/**
49+
* @desc Effective type of the connection meaning one of 'slow-2g', '2g', '3g', or '4g'.
50+
* This value is determined using a combination of recently observed round-trip time
51+
* and downlink values.
52+
*/
53+
effectiveType: INetworkInformation['effectiveType'] | undefined;
54+
/**
55+
* @desc Estimated effective round-trip time of the current connection, rounded
56+
* to the nearest multiple of 25 milliseconds
57+
*/
58+
rtt: INetworkInformation['rtt'] | undefined;
59+
/**
60+
* @desc {true} if the user has set a reduced data usage option on the user agent.
61+
*/
62+
saveData: INetworkInformation['saveData'] | undefined;
63+
/**
64+
* @desc The type of connection a device is using to communicate with the network.
65+
* It will be one of the following values:
66+
* - bluetooth
67+
* - cellular
68+
* - ethernet
69+
* - none
70+
* - wifi
71+
* - wimax
72+
* - other
73+
* - unknown
74+
*/
75+
type: INetworkInformation['type'] | undefined;
76+
}
77+
78+
const navigator:
79+
| (Navigator &
80+
Partial<Record<'connection' | 'mozConnection' | 'webkitConnection', INetworkInformation>>)
81+
| undefined = isBrowser ? window.navigator : undefined;
82+
83+
const conn: INetworkInformation | undefined =
84+
navigator && (navigator.connection || navigator.mozConnection || navigator.webkitConnection);
85+
86+
function getConnectionState(previousState?: IUseNetworkState): IUseNetworkState {
87+
const online = navigator?.onLine;
88+
const previousOnline = previousState?.online;
89+
90+
return {
91+
online,
92+
previous: previousOnline,
93+
since: online !== previousOnline ? new Date() : previousState?.since,
94+
downlink: conn?.downlink,
95+
downlinkMax: conn?.downlinkMax,
96+
effectiveType: conn?.effectiveType,
97+
rtt: conn?.rtt,
98+
saveData: conn?.saveData,
99+
type: conn?.type,
100+
};
101+
}
102+
103+
/**
104+
* Tracks the state of browser's network connection.
105+
*/
106+
export const useNetworkState: typeof navigator.connection extends undefined
107+
? undefined
108+
: (initialState?: IInitialState<IUseNetworkState>) => IUseNetworkState = isBrowser
109+
? function useNetworkState(initialState?: IInitialState<IUseNetworkState>): IUseNetworkState {
110+
const [state, setState] = useSafeState(initialState ?? getConnectionState);
111+
112+
useEffect(() => {
113+
const handleStateChange = () => {
114+
setState(getConnectionState);
115+
};
116+
117+
on(window, 'online', handleStateChange, { passive: true });
118+
on(window, 'offline', handleStateChange, { passive: true });
119+
120+
// it is quite hard to test it in jsdom environment maybe will be improved in future
121+
/* istanbul ignore next */
122+
if (conn) {
123+
on(conn, 'change', handleStateChange, { passive: true });
124+
}
125+
126+
return () => {
127+
off(window, 'online', handleStateChange);
128+
off(window, 'offline', handleStateChange);
129+
130+
/* istanbul ignore next */
131+
if (conn) {
132+
off(conn, 'change', handleStateChange);
133+
}
134+
};
135+
// eslint-disable-next-line react-hooks/exhaustive-deps
136+
}, []);
137+
138+
return state;
139+
}
140+
: (noop as () => undefined);

src/util/misc.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export function on<T extends EventTarget>(
2+
obj: T | null,
3+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4+
...args: Parameters<T['addEventListener']> | [string, CallableFunction | null, ...any]
5+
): void {
6+
if (obj && obj.addEventListener) {
7+
obj.addEventListener(...(args as Parameters<HTMLElement['addEventListener']>));
8+
}
9+
}
10+
11+
export function off<T extends EventTarget>(
12+
obj: T | null,
13+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
14+
...args: Parameters<T['removeEventListener']> | [string, CallableFunction | null, ...any]
15+
): void {
16+
if (obj && obj.removeEventListener) {
17+
obj.removeEventListener(...(args as Parameters<HTMLElement['removeEventListener']>));
18+
}
19+
}

stories/useMediatedState.story.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import {Canvas, Meta, Story} from '@storybook/addon-docs/blocks';
2-
import {Example} from './useMediatedState.stories';
1+
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
2+
import { Example } from './useMediatedState.stories';
33

44
<Meta title="State/useMediatedState" component={Example} />
55

@@ -23,5 +23,5 @@ Like `useState`, but every value set is passed through mediator function.
2323
function useMediatedState<S, R>(
2424
initialState?: S | (() => S),
2525
mediator?: (state: R) => S
26-
): [S, (value: R | ((prevState: S) => R)) => void]
26+
): [S, (value: R | ((prevState: S) => R)) => void];
2727
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React from 'react';
2+
import { useNetworkState } from '../src/useNetworkState';
3+
4+
export const Example: React.FC = () => {
5+
const onlineState = useNetworkState();
6+
7+
return (
8+
<div>
9+
<div>Your current internet connection state:</div>
10+
<pre>{JSON.stringify(onlineState, null, 2)}</pre>
11+
</div>
12+
);
13+
};

stories/useNetworkState.story.mdx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Example } from './useNetworkState.stories';
2+
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
3+
4+
<Meta title={'Sensor/useNetwork'} component={Example} />
5+
6+
# useNetwork
7+
8+
Tracks the state of browser's network connection.
9+
10+
> As of the standard, it is not guaranteed that browser connected to the _Internet_, it only
11+
> guarantees the network connection. [[MDN Docs]](https://developer.mozilla.org/en-US/docs/Web/API/NavigatorOnLine/onLine)
12+
13+
#### Example
14+
15+
<Canvas>
16+
<Story story={Example} inline />
17+
</Canvas>
18+
19+
## Reference
20+
21+
```ts
22+
export interface IUseNetworkState {
23+
online: boolean | undefined;
24+
previous: boolean | undefined;
25+
since: Date | undefined;
26+
downlink: number | undefined;
27+
downlinkMax: number | undefined;
28+
effectiveType: 'slow-2g' | '2g' | '3g' | '4g' | undefined;
29+
rtt: number | undefined;
30+
saveData: boolean | undefined;
31+
type:
32+
| 'bluetooth'
33+
| 'cellular'
34+
| 'ethernet'
35+
| 'none'
36+
| 'wifi'
37+
| 'wimax'
38+
| 'other'
39+
| 'unknown'
40+
| undefined;
41+
}
42+
43+
export function useNetworkState(initialState?: IInitialState<IUseNetworkState>): IUseNetworkState;
44+
```
45+
46+
#### Arguments
47+
48+
- _**initialState**_ _`IInitialState<IUseNetworkState>`_ - the value that will be used as default,
49+
unless real network state is resolved.

tests/dom/useNetworkState.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { act, renderHook } from '@testing-library/react-hooks/dom';
2+
import { useRef } from 'react';
3+
import { useNetworkState } from '../../src/useNetworkState';
4+
5+
describe(`useNetworkState`, () => {
6+
it('should be defined', () => {
7+
expect(useNetworkState).toBeDefined();
8+
});
9+
it('should render', () => {
10+
renderHook(() => useNetworkState());
11+
});
12+
13+
it('should return an object of certain structure', () => {
14+
const hook = renderHook(() => useNetworkState(), { initialProps: false });
15+
16+
expect(typeof hook.result.current).toEqual('object');
17+
expect(Object.keys(hook.result.current)).toEqual([
18+
'online',
19+
'previous',
20+
'since',
21+
'downlink',
22+
'downlinkMax',
23+
'effectiveType',
24+
'rtt',
25+
'saveData',
26+
'type',
27+
]);
28+
});
29+
30+
it('should rerender in case of online or offline events emitted on window', () => {
31+
const hook = renderHook(
32+
() => {
33+
const renderCount = useRef(0);
34+
return [useNetworkState(), ++renderCount.current];
35+
},
36+
{ initialProps: false }
37+
);
38+
39+
expect(hook.result.current[1]).toBe(1);
40+
const prevNWState = hook.result.current[0];
41+
42+
act(() => {
43+
window.dispatchEvent(new Event('online'));
44+
});
45+
expect(hook.result.current[1]).toBe(2);
46+
expect(hook.result.current[0]).not.toBe(prevNWState);
47+
});
48+
});

0 commit comments

Comments
 (0)