-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Revised eslint rules Simplified internal npm commands Reworked code to allow for more malleability when it comes to usage of this lib with new usePromiseState function to allow even better promise management Split codebase and tests into smaller files with less responsibility each
Showing
10 changed files
with
529 additions
and
344 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,58 +1,92 @@ | ||
import { useState, useEffect, useMemo, Dispatch, SetStateAction } from 'react'; | ||
import { useEffect, Dispatch, SetStateAction, useCallback, useMemo } from 'react'; | ||
|
||
import { PendingPromise, RejectedPromise, ResolvedPromise, SyncPromise, SyncPromiseState } from '.'; | ||
import { useHookedState } from './Unhook'; | ||
|
||
type SourceRef<T> = { source: T | Promise<T> }; | ||
|
||
/** | ||
* @description same behaviour as `useSate` but stops updates when unhooked | ||
* @see {useSate} | ||
* Loads given promise with built-in semaphor for later updates | ||
*/ | ||
const useHookedState = <S>(initialState: S): [state: S, dispatcher: Dispatch<SetStateAction<S>>] => { | ||
const ref = useMemo(() => ({ hooked: true }), []); | ||
const loadPromise = <T, E>(newSource: T | Promise<T>, ref: SourceRef<T>, setSyncPromise: Dispatch<SyncPromise<T, E>>): void => { | ||
Promise.resolve(newSource) | ||
.then((value): ResolvedPromise<T> => ({ state: SyncPromiseState.RESOLVED, value })) | ||
.catch((value: E): RejectedPromise<E> => ({ state: SyncPromiseState.REJECTED, value })) | ||
/** | ||
* Update state with result if the response came from | ||
* the same source as the one that is currently loaded | ||
*/ | ||
.then((newDerived) => { | ||
if (ref.source === newSource) { | ||
setSyncPromise(newDerived); | ||
} | ||
}); | ||
}; | ||
|
||
const [state, dispatcher] = useState<S>(initialState); | ||
/** All promises that are loading will point to this single object, which causes less re-renders */ | ||
const defaultSync: PendingPromise = { state: SyncPromiseState.PENDING }; | ||
|
||
useEffect( | ||
() => () => { | ||
ref.hooked = false; | ||
}, | ||
[], | ||
); | ||
/** | ||
* Handle promises synchronously in react! | ||
* | ||
* @example | ||
* const [promise, setPromise] = usePromiseState(Promise.resolve('Execute order 66')); | ||
* setPromise(Promise.resolve('Do, do not, there is no try')); | ||
* @example | ||
* const [promise, setPromise] = usePromiseState(Promise.resolve('There are decades when nothing happens')); | ||
* setPromise<string, Error>(Promise.resolve('there are weeks, where decades happen')); | ||
* | ||
* @param asyncPromise the promise you want to handle synchronously, don't forget to memoize it :) | ||
* | ||
* @returns a tupple capable of updating itself based on Promises on the react life-cycle generating react hooked objects | ||
*/ | ||
export const usePromiseState = <T, E = unknown>(asyncPromise: T | Promise<T>): [syncPromise: SyncPromise<T, E>, dispatcher: Dispatch<SetStateAction<T | Promise<T>>>] => { | ||
/** A reference to the original promise, that will never be updated */ | ||
const originalPromiseRef = useMemo<SourceRef<T>>(() => ({ source: asyncPromise }), []); | ||
|
||
return [ | ||
state, | ||
(...args) => { | ||
if (ref.hooked) { | ||
dispatcher(...args); | ||
/** The derived sync promise */ | ||
const [syncPromise, setSyncPromise] = useHookedState<SyncPromise<T, E> | null>(null); | ||
|
||
const callback = useCallback( | ||
(action: SetStateAction<T | Promise<T>>) => { | ||
const newSource = action instanceof Function ? action(originalPromiseRef.source) : action; | ||
|
||
if (newSource === originalPromiseRef.source) { | ||
/** Source is already loaded, do nothing */ | ||
return; | ||
} | ||
|
||
/** Point the ref to the new source */ | ||
originalPromiseRef.source = newSource; | ||
|
||
/** Mark it as loading again */ | ||
setSyncPromise(null); | ||
|
||
/** Schedule promise to load */ | ||
loadPromise(newSource, originalPromiseRef, setSyncPromise); | ||
}, | ||
]; | ||
}; | ||
[originalPromiseRef], | ||
); | ||
|
||
/** Force initial load */ | ||
useEffect(() => loadPromise(originalPromiseRef.source, originalPromiseRef, setSyncPromise), [originalPromiseRef]); | ||
|
||
const initial: PendingPromise = { state: SyncPromiseState.PENDING }; | ||
return [syncPromise || defaultSync, callback]; | ||
}; | ||
|
||
/** | ||
* Handle promises synchronously in react! | ||
* | ||
* @example usePromise(Promise.resolve('Execute order 66')) | ||
* @example usePromise<string, Error>(Promise.resolve('Execute order 66')) | ||
* | ||
* @param asyncPromise the promise you want to handle synchronously | ||
* @param asyncPromise the promise you want to handle synchronously, don't forget to memoize it :) | ||
*/ | ||
export const usePromise = <T, E = unknown>(asyncPromise: T | Promise<T>): SyncPromise<T, E> => { | ||
const [promise, setPromise] = useHookedState<SyncPromise<T, E> | null>(null); | ||
|
||
useEffect(() => { | ||
/** Reset state */ | ||
setPromise(null); | ||
|
||
const guarantiedPromise = asyncPromise instanceof Promise ? asyncPromise : Promise.resolve(asyncPromise); | ||
const [promise, updateSourcePromise] = usePromiseState<T, E>(asyncPromise); | ||
|
||
guarantiedPromise | ||
.then((value): ResolvedPromise<T> => ({ state: SyncPromiseState.RESOLVED, value })) | ||
.catch((value: E): RejectedPromise<E> => ({ state: SyncPromiseState.REJECTED, value })) | ||
/** Update state with result */ | ||
.then((r) => setPromise(r)); | ||
}, [asyncPromise]); | ||
/** If the promise changes, then update it accordingly */ | ||
useEffect(() => updateSourcePromise(asyncPromise), [asyncPromise, updateSourcePromise]); | ||
|
||
return promise || initial; | ||
return promise; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
import React, { Dispatch, FC, SetStateAction, useEffect, useState } from 'react'; | ||
import { fireEvent, render, act } from '@testing-library/react'; | ||
|
||
import { useHookedState } from './Unhook'; | ||
|
||
import { delay } from '../test'; | ||
|
||
type Dispatcher = Dispatch<SetStateAction<number>>; | ||
|
||
const Internal: FC<{ onRender: (dispatcher: Dispatcher) => void }> = ({ onRender }) => { | ||
const [counter, setCounter] = useHookedState(0); | ||
|
||
useEffect(() => onRender(setCounter), []); | ||
|
||
return <span>{`Count ${counter}`}</span>; | ||
}; | ||
|
||
const Helper: FC = ({ children }) => { | ||
const [show, setShow] = useState(true); | ||
|
||
return ( | ||
<> | ||
<button type="button" aria-label="testing-button" onClick={() => setShow(!show)} /> | ||
{show && children} | ||
</> | ||
); | ||
}; | ||
|
||
describe(useHookedState, () => { | ||
it('only updates state if it is hooked', () => act(async () => { | ||
/** Hooks */ | ||
const errorLog = jest.spyOn(console, 'error').mockReturnValue(); | ||
|
||
const onRender = jest.fn<void, [Dispatcher]>(); | ||
const { container } = render( | ||
<Helper> | ||
<Internal onRender={onRender} /> | ||
</Helper>, | ||
); | ||
|
||
await delay(0); | ||
|
||
/** Everything is rendered */ | ||
expect(onRender).toBeCalledTimes(1); | ||
expect(container.querySelector('span')?.textContent).toBe('Count 0'); | ||
|
||
/** Change count */ | ||
onRender.mock.calls[0][0](1); | ||
|
||
/** Value is updated */ | ||
expect(container.querySelector('span')?.textContent).toBe('Count 1'); | ||
|
||
/** Hide hook */ | ||
const button = container.querySelector('button'); | ||
if (!button) { | ||
throw new Error('Button not rendered'); | ||
} | ||
fireEvent.click(button); | ||
|
||
/** Span is hidden */ | ||
expect(container.querySelector('span')?.textContent).toBeUndefined(); | ||
|
||
/** Change count again */ | ||
onRender.mock.calls[0][0](2); | ||
|
||
/** React did NOT complained about anything as expected */ | ||
expect(errorLog).toBeCalledTimes(0); | ||
})); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { useState, useEffect, useMemo, Dispatch, SetStateAction, useCallback } from 'react'; | ||
|
||
/** | ||
* @description same behaviour as `useSate` but stops updates when unhooked | ||
* @see {useSate} | ||
*/ | ||
export const useHookedState = <S>(initialState: S): [state: S, dispatcher: Dispatch<SetStateAction<S>>] => { | ||
const ref = useMemo(() => ({ hooked: true }), []); | ||
|
||
const [state, dispatcher] = useState<S>(initialState); | ||
|
||
/** | ||
* Update when unhooked | ||
*/ | ||
useEffect( | ||
() => () => { | ||
ref.hooked = false; | ||
}, | ||
[], | ||
); | ||
|
||
const hookedDispatcher: Dispatch<SetStateAction<S>> = useCallback( | ||
(...args) => { | ||
if (ref.hooked) { | ||
dispatcher(...args); | ||
} | ||
}, | ||
[dispatcher, ref], | ||
); | ||
|
||
return [state, hookedDispatcher]; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import { ifUnresolved, ifNotRejected, SyncPromiseState, isPending, isRejected, isResolved } from '.'; | ||
|
||
describe(ifUnresolved, () => { | ||
it('ifUnresolved detects unresolved promises and gives out alternative value', () => { | ||
expect(ifUnresolved({ state: SyncPromiseState.PENDING }, 'alternative')).toBe('alternative'); | ||
expect(ifUnresolved({ state: SyncPromiseState.REJECTED, value: 'rejection' }, 'alternative')).toBe('alternative'); | ||
expect(ifUnresolved({ state: SyncPromiseState.RESOLVED, value: 'resolution' }, 'alternative')).toBe('resolution'); | ||
}); | ||
}); | ||
|
||
describe(ifNotRejected, () => { | ||
it('ifNotRejected detects non rejected promises and gives out alternative value', () => { | ||
expect(ifNotRejected({ state: SyncPromiseState.PENDING }, 'alternative')).toBe('alternative'); | ||
expect(ifNotRejected({ state: SyncPromiseState.REJECTED, value: 'rejection' }, 'alternative')).toBe('rejection'); | ||
expect(ifNotRejected({ state: SyncPromiseState.RESOLVED, value: 'resolution' }, 'alternative')).toBe('alternative'); | ||
}); | ||
}); | ||
|
||
describe(isPending, () => { | ||
it('isPending detects promises that are still loading', () => { | ||
expect(isPending({ state: SyncPromiseState.PENDING })).toBe(true); | ||
expect(isPending({ state: SyncPromiseState.REJECTED, value: 'rejection' })).toBe(false); | ||
expect(isPending({ state: SyncPromiseState.RESOLVED, value: 'resolution' })).toBe(false); | ||
}); | ||
}); | ||
|
||
describe(isRejected, () => { | ||
it('isPending detects promises that are still loading', () => { | ||
expect(isRejected({ state: SyncPromiseState.PENDING })).toBe(false); | ||
expect(isRejected({ state: SyncPromiseState.REJECTED, value: 'rejection' })).toBe(true); | ||
expect(isRejected({ state: SyncPromiseState.RESOLVED, value: 'resolution' })).toBe(false); | ||
}); | ||
}); | ||
|
||
describe(isResolved, () => { | ||
it('isPending detects promises that are still loading', () => { | ||
expect(isResolved({ state: SyncPromiseState.PENDING })).toBe(false); | ||
expect(isResolved({ state: SyncPromiseState.REJECTED, value: 'rejection' })).toBe(false); | ||
expect(isResolved({ state: SyncPromiseState.RESOLVED, value: 'resolution' })).toBe(true); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export const delay = async (milliseconds: number): Promise<void> => new Promise((resolve) => { | ||
setTimeout(() => resolve(), milliseconds); | ||
}); |