Skip to content

Commit

Permalink
feat: new hook, useNetworkState
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi committed May 2, 2021
1 parent d181c7f commit 8b81063
Show file tree
Hide file tree
Showing 9 changed files with 294 additions and 22 deletions.
18 changes: 11 additions & 7 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ first.
4. Install dependencies: `yarn`
5. Make sure everything builds and tests: `yarn build && yarn test`
6. Create the branch for your PR, like: `git checkout -b pr/my-awesome-hook`
- in case you are adding a new hook - it is better to name your branch by the hook: `pr/useUpdateEffect`
- in case you are adding a new hook - it is better to name your branch by the
hook: `pr/useUpdateEffect`
- in case your change fixes an issue - it is better to name branch by the issue
id: `pr/fix-12345`
7. Follow the directions below
Expand All @@ -40,14 +41,14 @@ first.
- In case hook has some custom types as arguments or return values - it should also be exported.
- All types and interfaces should be `I` prefixed.
- Hook should be developed with SSR in mind.
- In case hook is stateful and exposes `setState` method it should use `useSafeState` instead of
`useState`, since `useSafeState`.
- In case hook is stateful and exposes `setState` method, or is has async callbacks (that can
resolve aster component unmount), it should use `useSafeState` instead of `useState`.
2. Reexport hook implementation and all custom types in `src/index.ts`.
3. Write complete tests for your hook, tests should consist of both DOM and SSR parts.
- Hook's test should be placed in `tests` folder and named after the hook.
4ex: `test/dom/useFirstMountState.test.ts` and `test/ssr/useFirstMountState.test.ts`.
- Ideally your hook should have 100% test coverage. For cases where that is impossible,
you should comment above the code exactly why it is impossible to have 100% coverage.
- Ideally your hook should have 100% test coverage. For cases where that is impossible, you
should comment above the code exactly why it is impossible to have 100% coverage.
- Each hook should have at least 'should be defined' and 'should render' tests in `SSR`
environment.
- All utility functions should also be tested.
Expand All @@ -59,9 +60,12 @@ first.
- Components representing hook functionality should be placed in file named after the hook
with `.stories` suffix.
4ex: `useFirstMountState.stories.tsx`.
- Preferred format to write the docs is MDX. [Read more about storybook docs](https://storybook.js.org/docs/react/writing-docs/introduction).
- Preferred format to write the docs is
MDX. [Read more about storybook docs](https://storybook.js.org/docs/react/writing-docs/introduction)
.
5. Add docs link and hook summary to the `README.md`.
6. After all above steps are done - run `yarn lint:fix` and ensure that everything is styled by our standards.
6. After all above steps are done - run `yarn lint:fix` and ensure that everything is styled by our
standards.
## Committing
Expand Down
29 changes: 17 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,30 +51,35 @@ import { useMountEffect } from "@react-hookz/web/esnext";
## Hooks list

- #### Lifecycle
- [`useConditionalEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionaleffect)

- [`useConditionalEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionaleffect--example)
— Like `useEffect` but callback invoked only if conditions match predicate.
- [`useConditionalUpdateEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionalupdateeffect)
- [`useConditionalUpdateEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useconditionalupdateeffect--example)
— Like `useUpdateEffect` but callback invoked only if conditions match predicate.
- [`useFirstMountState`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usefirstmountstate)
- [`useFirstMountState`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usefirstmountstate--example)
— Return boolean that is `true` only on first render.
- [`useIsMounted`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useismounted)
- [`useIsMounted`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useismounted--example)
— Returns function that yields current mount state.
- [`useMountEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usemounteffect)
- [`useMountEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usemounteffect--example)
— Run effect only when component is first mounted.
- [`useRerender`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usererender)
- [`useRerender`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usererender--example)
— Return callback that re-renders component.
- [`useUnmountEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useunmounteffect)
- [`useUnmountEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useunmounteffect--example)
— Run effect only when component is unmounted.
- [`useUpdateEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useupdateeffect)
- [`useUpdateEffect`](https://react-hookz.github.io/web/?path=/docs/lifecycle-useupdateeffect--example)
— Effect hook that ignores the first render (not invoked on mount).

- #### State
- [`useMediatedState`](https://react-hookz.github.io/web/?path=/docs/lifecycle-usemediatedstate)

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

- ### Sensor
- [`useNetworkState`](http://localhost:6006/?path=/docs/sensor-usenetwork--example)
— Tracks the state of browser's network connection.
137 changes: 137 additions & 0 deletions src/useNetworkState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useEffect } from 'react';
import { isBrowser, noop } from './util/const';
import { IInitialState } from './util/resolveHookState';
import { useSafeState } from './useSafeState';
import { off, on } from './util/misc';

export interface INetworkInformation extends EventTarget {
readonly downlink: number;
readonly downlinkMax: number;
readonly effectiveType: 'slow-2g' | '2g' | '3g' | '4g';
readonly rtt: number;
readonly saveData: boolean;
readonly type:
| 'bluetooth'
| 'cellular'
| 'ethernet'
| 'none'
| 'wifi'
| 'wimax'
| 'other'
| 'unknown';
}

export interface IUseNetworkState {
/**
* @desc Whether browser connected to the network or not.
*/
online: boolean | undefined;
/**
* @desc Previous value of `online` property. Helps to identify if browser
* just connected or lost connection.
*/
previous: boolean | undefined;
/**
* @desc The {Date} object pointing to the moment when state change occurred.
*/
since: Date | undefined;
/**
* @desc Effective bandwidth estimate in megabits per second, rounded to the
* nearest multiple of 25 kilobits per seconds.
*/
downlink: INetworkInformation['downlink'] | undefined;
/**
* @desc Maximum downlink speed, in megabits per second (Mbps), for the
* underlying connection technology
*/
downlinkMax: INetworkInformation['downlinkMax'] | undefined;
/**
* @desc Effective type of the connection meaning one of 'slow-2g', '2g', '3g', or '4g'.
* This value is determined using a combination of recently observed round-trip time
* and downlink values.
*/
effectiveType: INetworkInformation['effectiveType'] | undefined;
/**
* @desc Estimated effective round-trip time of the current connection, rounded
* to the nearest multiple of 25 milliseconds
*/
rtt: INetworkInformation['rtt'] | undefined;
/**
* @desc {true} if the user has set a reduced data usage option on the user agent.
*/
saveData: INetworkInformation['saveData'] | undefined;
/**
* @desc The type of connection a device is using to communicate with the network.
* It will be one of the following values:
* - bluetooth
* - cellular
* - ethernet
* - none
* - wifi
* - wimax
* - other
* - unknown
*/
type: INetworkInformation['type'] | undefined;
}

const navigator:
| (Navigator &
Partial<Record<'connection' | 'mozConnection' | 'webkitConnection', INetworkInformation>>)
| undefined = isBrowser ? window.navigator : undefined;

const conn: INetworkInformation | undefined =
navigator && (navigator.connection || navigator.mozConnection || navigator.webkitConnection);

function getConnectionState(previousState?: IUseNetworkState): IUseNetworkState {
const online = navigator?.onLine;
const previousOnline = previousState?.online;

return {
online,
previous: previousOnline,
since: online !== previousOnline ? new Date() : previousState?.since,
downlink: conn?.downlink,
downlinkMax: conn?.downlinkMax,
effectiveType: conn?.effectiveType,
rtt: conn?.rtt,
saveData: conn?.saveData,
type: conn?.type,
};
}

/**
* Tracks the state of browser's network connection.
*/
export const useNetworkState: typeof navigator.connection extends undefined
? undefined
: (initialState?: IInitialState<IUseNetworkState>) => IUseNetworkState = isBrowser
? function useNetworkState(initialState?: IInitialState<IUseNetworkState>): IUseNetworkState {
const [state, setState] = useSafeState(initialState ?? getConnectionState);

useEffect(() => {
const handleStateChange = () => {
setState(getConnectionState);
};

on(window, 'online', handleStateChange, { passive: true });
on(window, 'offline', handleStateChange, { passive: true });

if (conn) {
on(conn, 'change', handleStateChange, { passive: true });
}

return () => {
off(window, 'online', handleStateChange);
off(window, 'offline', handleStateChange);

if (conn) {
off(conn, 'change', handleStateChange);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return state;
}
: (noop as () => undefined);
19 changes: 19 additions & 0 deletions src/util/misc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function on<T extends Window | Document | HTMLElement | EventTarget>(
obj: T | null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: Parameters<T['addEventListener']> | [string, CallableFunction | null, ...any]
): void {
if (obj && obj.addEventListener) {
obj.addEventListener(...(args as Parameters<HTMLElement['addEventListener']>));
}
}

export function off<T extends Window | Document | HTMLElement | EventTarget>(
obj: T | null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...args: Parameters<T['removeEventListener']> | [string, CallableFunction | null, ...any]
): void {
if (obj && obj.removeEventListener) {
obj.removeEventListener(...(args as Parameters<HTMLElement['removeEventListener']>));
}
}
6 changes: 3 additions & 3 deletions stories/useMediatedState.story.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Canvas, Meta, Story} from '@storybook/addon-docs/blocks';
import {Example} from './useMediatedState.stories';
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './useMediatedState.stories';

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

Expand All @@ -23,5 +23,5 @@ Like `useState`, but every value set is passed through mediator function.
function useMediatedState<S, R>(
initialState?: S | (() => S),
mediator?: (state: R) => S
): [S, (value: R | ((prevState: S) => R)) => void]
): [S, (value: R | ((prevState: S) => R)) => void];
```
13 changes: 13 additions & 0 deletions stories/useNetworkState.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import { useNetworkState } from '../src/useNetworkState';

export const Example: React.FC = () => {
const onlineState = useNetworkState();

return (
<div>
<div>Your current internet connection state:</div>
<pre>{JSON.stringify(onlineState, null, 2)}</pre>
</div>
);
};
49 changes: 49 additions & 0 deletions stories/useNetworkState.story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Example } from './useNetworkState.stories';
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';

<Meta title={'Sensor/useNetwork'} component={Example} />

# useNetwork

Tracks the state of browser's network connection.

> As of the standard, it is not guaranteed that browser connected to the _Internet_, it only
> guarantees the network connection.
#### Example

<Canvas>
<Story story={Example} inline />
</Canvas>

## Reference

```ts
export interface IUseNetworkState {
online: boolean | undefined;
previous: boolean | undefined;
since: Date | undefined;
downlink: number | undefined;
downlinkMax: number | undefined;
effectiveType: 'slow-2g' | '2g' | '3g' | '4g' | undefined;
rtt: number | undefined;
saveData: boolean | undefined;
type:
| 'bluetooth'
| 'cellular'
| 'ethernet'
| 'none'
| 'wifi'
| 'wimax'
| 'other'
| 'unknown'
| undefined;
}

export function useNetworkState(initialState?: IInitialState<IUseNetworkState>): IUseNetworkState;
```

#### Arguments

- _**initialState**_ _`IInitialState<IUseNetworkState>`_ - the value that will be used as default,
unless real network state is resolved.
28 changes: 28 additions & 0 deletions tests/dom/useNetworkState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { renderHook } from '@testing-library/react-hooks/dom';
import { useNetworkState } from '../../src/useNetworkState';

describe(`useNetworkState`, () => {
it('should be defined', () => {
expect(useNetworkState).toBeDefined();
});
it('should render', () => {
renderHook(() => useNetworkState());
});

it('should return an object of certain structure', () => {
const hook = renderHook(() => useNetworkState(), { initialProps: false });

expect(typeof hook.result.current).toEqual('object');
expect(Object.keys(hook.result.current)).toEqual([
'online',
'previous',
'since',
'downlink',
'downlinkMax',
'effectiveType',
'rtt',
'saveData',
'type',
]);
});
});
17 changes: 17 additions & 0 deletions tests/ssr/useNetworkState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { renderHook } from '@testing-library/react-hooks/server';
import { useNetworkState } from '../../src/useNetworkState';

describe(`useNetworkState`, () => {
it('should be defined', () => {
expect(useNetworkState).toBeDefined();
});
it('should render', () => {
renderHook(() => useNetworkState());
});

it('should have undefined state', () => {
const hook = renderHook(() => useNetworkState(), { initialProps: false });

expect(hook.result.current).toBeUndefined();
});
});

0 comments on commit 8b81063

Please sign in to comment.