Skip to content

Commit

Permalink
feat: New hooks and server-side compatibility (#203)
Browse files Browse the repository at this point in the history
  • Loading branch information
tassoevan authored Apr 22, 2020
1 parent 5ab9f6a commit ca620a0
Show file tree
Hide file tree
Showing 26 changed files with 586 additions and 74 deletions.
1 change: 1 addition & 0 deletions packages/fuselage-hooks/.flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ flow-typed
[lints]

[options]
esproposal.optional_chaining=enable

[strict]
14 changes: 14 additions & 0 deletions packages/fuselage-hooks/.jest/helpers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useReducer, Component, createElement } from 'react';
import ReactDOM, { render, unmountComponentAtNode } from 'react-dom';
import { renderToString } from 'react-dom/server';
import { act } from 'react-dom/test-utils';

export const runHooks = (fn, mutations = []) => {
Expand Down Expand Up @@ -55,3 +56,16 @@ export const runHooks = (fn, mutations = []) => {

return values;
};

export const runHooksOnServer = (fn) => {
let returnedValue;

function FunctionalComponent() {
returnedValue = fn();
return null;
}

renderToString(<FunctionalComponent />);

return returnedValue;
};
69 changes: 54 additions & 15 deletions packages/fuselage-hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,43 @@ yarn test

#### Table of Contents

- [useClassName](#useclassname)
- [useAutoFocus](#useautofocus)
- [Parameters](#parameters)
- [useDebouncedUpdates](#usedebouncedupdates)
- [useClassName](#useclassname)
- [Parameters](#parameters-1)
- [useDebouncedReducer](#usedebouncedreducer)
- [useDebouncedUpdates](#usedebouncedupdates)
- [Parameters](#parameters-2)
- [useDebouncedState](#usedebouncedstate)
- [useDebouncedReducer](#usedebouncedreducer)
- [Parameters](#parameters-3)
- [useDebouncedCallback](#usedebouncedcallback)
- [useDebouncedState](#usedebouncedstate)
- [Parameters](#parameters-4)
- [useExclusiveBooleanProps](#useexclusivebooleanprops)
- [useDebouncedCallback](#usedebouncedcallback)
- [Parameters](#parameters-5)
- [useMediaQuery](#usemediaquery)
- [useDebouncedValue](#usedebouncedvalue)
- [Parameters](#parameters-6)
- [useMergedRefs](#usemergedrefs)
- [useLazyRef](#uselazyref)
- [Parameters](#parameters-7)
- [useMutableCallback](#usemutablecallback)
- [useMediaQuery](#usemediaquery)
- [Parameters](#parameters-8)
- [useToggle](#usetoggle)
- [useMergedRefs](#usemergedrefs)
- [Parameters](#parameters-9)
- [useMutableCallback](#usemutablecallback)
- [Parameters](#parameters-10)
- [useSafely](#usesafely)
- [Parameters](#parameters-11)
- [useToggle](#usetoggle)
- [Parameters](#parameters-12)

### useAutoFocus

Hook to automatically request focus for an DOM element.

#### Parameters

- `isFocused` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** if true, the focus will be requested (optional, default `true`)
- `options` **FocusOptions** options of the focus request

Returns **any** the ref which holds the element

### useClassName

Expand Down Expand Up @@ -110,17 +127,26 @@ Hook to memoize a debounced version of a callback.

Returns **function (): any** a memoized and debounced callback

### useExclusiveBooleanProps
### useDebouncedValue

Hook for asserting mutually exclusive boolean props. Useful for components that use boolean props
to choose styling variants.
Hook to keep a debounced reference of a value.

#### Parameters

- `props` **[Object](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object)** the mutually exclusive boolean props
- `value` **any** the value to be debounced
- `delay` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** the number of milliseconds to delay

Returns **any** a debounced value

### useLazyRef

Hook equivalent to useRef, but with a lazy initialization for computed value.

#### Parameters

- Throws **any** if two or more booleans props are set as true
- `initializer` **function (): T** the function the computes the ref value

Returns **any** the ref

### useMediaQuery

Expand Down Expand Up @@ -153,6 +179,19 @@ Hook to create a stable callback from a mutable one.

Returns **any** a stable callback

### useSafely

Hook that wraps pairs of state and updater to provide a new updater which
can be safe and asynchronically called even after the component unmounted.

#### Parameters

- `pair` **\[any, function (): any]** the state and updater pair which will be patched
- `pair.0` the state value
- `pair.1` the state updater function

Returns **any** a state value and safe updater pair

### useToggle

Hook to create a toggleable boolean state.
Expand Down
2 changes: 2 additions & 0 deletions packages/fuselage-hooks/src/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,5 @@ export const debounce = (fn: (...Array<any>) => any, delay: number) => {

return f;
};

export const isRunningOnBrowser = typeof window !== 'undefined' && window.document;
5 changes: 4 additions & 1 deletion packages/fuselage-hooks/src/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// @flow

export * from './useAutoFocus';
export * from './useClassName';
export * from './useDebouncedUpdates';
export * from './useDebouncedCallback';
export * from './useExclusiveBooleanProps';
export * from './useDebouncedValue';
export * from './useLazyRef';
export * from './useMediaQuery';
export * from './useMergedRefs';
export * from './useMutableCallback';
export * from './useSafely';
export * from './useToggle';
export * from './useUniqueId';
26 changes: 26 additions & 0 deletions packages/fuselage-hooks/src/useAutoFocus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// @flow

import { useEffect, useRef } from 'react';

type FocusOptions = {
preventScroll?: boolean,
} | typeof undefined;

/**
* Hook to automatically request focus for an DOM element.
*
* @param isFocused if true, the focus will be requested
* @param options options of the focus request
* @return the ref which holds the element
*/
export const useAutoFocus = (isFocused: boolean = true, options: FocusOptions) => {
const elementRef = useRef<?HTMLElement>();

useEffect(() => {
if (isFocused && elementRef.current) {
elementRef.current.focus(options);
}
}, [elementRef, isFocused]);

return elementRef;
};
26 changes: 26 additions & 0 deletions packages/fuselage-hooks/src/useDebouncedValue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// @flow

import { useEffect, useState } from 'react';

/**
* Hook to keep a debounced reference of a value.
*
* @param value the value to be debounced
* @param delay the number of milliseconds to delay
* @return a debounced value
*/
export const useDebouncedValue = (value: any, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => {
clearTimeout(timer);
};
}, [value, delay]);

return debouncedValue;
};
16 changes: 0 additions & 16 deletions packages/fuselage-hooks/src/useExclusiveBooleanProps.js

This file was deleted.

16 changes: 16 additions & 0 deletions packages/fuselage-hooks/src/useLazyRef.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// @flow

import { createRef, useState } from 'react';

/**
* Hook equivalent to useRef, but with a lazy initialization for computed value.
*
* @param initializer the function the computes the ref value
* @return the ref
*/
export const useLazyRef = <T>(initializer: () => T) =>
useState(() => {
const ref = createRef<T>();
ref.current = initializer();
return ref;
})[0];
26 changes: 11 additions & 15 deletions packages/fuselage-hooks/src/useMediaQuery.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// @flow

import { useLayoutEffect, useState } from 'react';
import { useEffect, useState } from 'react';

import { isRunningOnBrowser } from './helpers';

/**
* Hook to listen to a media query.
Expand All @@ -10,36 +12,30 @@ import { useLayoutEffect, useState } from 'react';
*/
export const useMediaQuery = (query: string): bool => {
const [matches, setMatches] = useState(() => {
if (!query) {
if (!query || !isRunningOnBrowser) {
return false;
}

const { matches } = window.matchMedia(query);
return !!matches;
});

useLayoutEffect(() => {
if (!query) {
useEffect(() => {
if (!query || !isRunningOnBrowser) {
return;
}

let mounted = true;
const mql = window.matchMedia(query);
const mediaQueryListener = window.matchMedia(query);
setMatches(mediaQueryListener.matches);

const handleChange = () => {
if (!mounted) {
return;
}

setMatches(!!mql.matches);
setMatches(!!mediaQueryListener.matches);
};

mql.addListener(handleChange);
setMatches(mql.matches);
mediaQueryListener.addListener(handleChange);

return () => {
mounted = false;
mql.removeListener(handleChange);
mediaQueryListener.removeListener(handleChange);
};
}, [query]);

Expand Down
36 changes: 36 additions & 0 deletions packages/fuselage-hooks/src/useSafely.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @flow

import { useEffect, useRef } from 'react';

import { useMutableCallback } from './useMutableCallback';

/**
* Hook that wraps pairs of state and updater to provide a new updater which
* can be safe and asynchronically called even after the component unmounted.
*
* @param pair - the state and updater pair which will be patched
* @param pair.0 - the state value
* @param pair.1 - the state updater function
* @return a state value and safe updater pair
*/
export const useSafely = ([state, updater]: [any, () => any]) => {
const mountedRef = useRef(true);

useEffect(() => {
mountedRef.current = true;

return () => {
mountedRef.current = false;
};
});

const safeUpdater = useMutableCallback((...args) => {
if (!mountedRef.current) {
return;
}

updater(...args);
});

return [state, safeUpdater];
};
51 changes: 51 additions & 0 deletions packages/fuselage-hooks/tests/useAutoFocus.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useState } from 'react';

import { runHooks } from '../.jest/helpers';
import { useAutoFocus } from '../src';

describe('useAutoFocus hook', () => {
it('returns a ref', () => {
const [ref] = runHooks(() => useAutoFocus());

expect(ref).toMatchObject({ current: undefined });
});

it('invokes focus', () => {
const focus = jest.fn();
runHooks(() => useAutoFocus(), [
(ref) => {
ref.current = { focus };
},
]);

expect(focus).toHaveBeenCalledTimes(1);
});

it('does not invoke focus if isFocused is false', () => {
const focus = jest.fn();
runHooks(() => useAutoFocus(false), [
(ref) => {
ref.current = { focus };
},
]);

expect(focus).toHaveBeenCalledTimes(0);
});

it('invokes focus if isFocused is toggled', () => {
const focus = jest.fn();
runHooks(() => {
const [isFocused, setFocused] = useState(false);
return [useAutoFocus(isFocused), setFocused];
}, [
([ref]) => {
ref.current = { focus };
},
([, setFocused]) => {
setFocused(true);
},
]);

expect(focus).toHaveBeenCalledTimes(1);
});
});
Loading

0 comments on commit ca620a0

Please sign in to comment.