Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: new hook, useNetworkState #35

Merged
merged 4 commits into from
May 3, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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();
});
});