Skip to content

Commit

Permalink
New 1.0.8 version
Browse files Browse the repository at this point in the history
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
filipomar authored Jul 3, 2022

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent 55c5e26 commit a97bb0f
Showing 10 changed files with 529 additions and 344 deletions.
21 changes: 9 additions & 12 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -15,23 +15,20 @@
},
"plugins": ["react", "@typescript-eslint"],
"rules": {
"no-shadow": "off",
"@typescript-eslint/no-shadow": "error",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error"],
"import/extensions": "off",
"import/no-extraneous-dependencies": ["error", { "devDependencies": ["**/*.test.tsx", "test/**"] }],
"import/no-unresolved": "off",
"import/prefer-default-export": "off",
"indent": ["error", 4],
"react/jsx-indent": ["error", 4],
"max-len": ["error", 180],
"no-console": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error"],
"object-curly-newline": ["error", { "ObjectPattern": { "multiline": true }, "ExportDeclaration": { "multiline": true } }],
"prefer-promise-reject-errors": "off",
"react/function-component-definition": "off",
"react/jsx-filename-extension": ["error", { "extensions": [".tsx"] }],
"react/jsx-indent": ["error", 4],
"react/prop-types": "off",
"react/require-default-props": "off",
"react/no-unused-prop-types": "off",
"import/extensions": "off",
"no-shadow": "off",
"@typescript-eslint/no-shadow": "error",
"import/prefer-default-export": "off"
"react/jsx-filename-extension": ["error", { "extensions": [".tsx"] }]
}
}
31 changes: 19 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
# react-sync-promise

A small react helper snippet to handle promises as a react synchronous hook with mininmal amount of re-renders
A simple react helper snippet to handle promises as a react synchronous hook with mininmal amount of re-renders

## Usage

```tsx
import React, { FC } from 'react';
import { usePromise, isPending, isRejected, isResolved, ifUnresolved, ifNotRejected } from 'react-sync-promise';
import React, { FC, useMemo } from 'react';
import { usePromise, isPending, isRejected, isResolved, ifUnresolved, ifNotRejected, usePromiseState } from 'react-sync-promise';

export const PrequelsSurprise: FC = () => {
const syncPromise = usePromise(Promise.resolve('Execute order 66'));
const memoizedPromise = useMemo(() => Promise.resolve('Execute order 66'), []);

const syncPromise = usePromise(memoizedPromise);
const [secondSyncPromise, setSecondSyncPromise] = usePromiseState(memoizedPromise);

return (
<ul>
<li>{`JSON: ${JSON.stringify(syncPromise)}`}</li>
<li>{`isPending: ${String(isPending(syncPromise))}`}</li>
<li>{`isRejected: ${String(isRejected(syncPromise))}`}</li>
<li>{`isResolved: ${String(isResolved(syncPromise))}`}</li>
<li>{`ifUnresolved: ${String(ifUnresolved(syncPromise, 'Hello There'))}`}</li>
<li>{`ifNotRejected: ${String(ifNotRejected(syncPromise, 'General kenobi'))}`}</li>
</ul>
<>
<p>
{`JSON: ${JSON.stringify(secondSyncPromise)}`}
<button type="button" aria-label="Update Promise" onClick={() => setSecondSyncPromise(Promise.resolve('This is where the fun begins'))} />
</p>
<p>{`JSON: ${JSON.stringify(syncPromise)}`}</p>
<p>{`isPending: ${String(isPending(syncPromise))}`}</p>
<p>{`isRejected: ${String(isRejected(syncPromise))}`}</p>
<p>{`isResolved: ${String(isResolved(syncPromise))}`}</p>
<p>{`ifUnresolved: ${String(ifUnresolved(syncPromise, 'Hello There'))}`}</p>
<p>{`ifNotRejected: ${String(ifNotRejected(syncPromise, 'General kenobi'))}`}</p>
</>
);
};
```
178 changes: 89 additions & 89 deletions package-lock.json

Large diffs are not rendered by default.

15 changes: 7 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-sync-promise",
"version": "1.0.7",
"description": "A small react helper snippet to handle promises as a react synchronous hook with mininmal amount of re-renders",
"version": "1.0.8",
"description": "A simple react helper snippet to handle promises as a react synchronous hook with mininmal amount of re-renders",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"typings": "dist/index.d.ts",
@@ -10,12 +10,11 @@
"url": "git+https://github.com/filipomar/react-sync-promise.git"
},
"scripts": {
"audit": "npm run prettier && npm run lint && npm run coverage && npm run build",
"audit": "npm run format && npm run coverage && npm run build",
"preversion": "npm run audit",
"prepublishOnly": "npm run audit",
"build": "rm -rf dist/ && tsc --project buildtsconfig.json",
"lint": "eslint --fix '**/*.{ts,tsx}'",
"prettier": "prettier --config .prettierrc.json --write --check --loglevel error .",
"format": "prettier --config .prettierrc.json --write --check --loglevel error . && eslint --fix '**/*.{ts,tsx}'",
"test": "jest --no-cache --maxWorkers=2",
"coverage": "jest --no-cache --coverage --maxWorkers=2"
},
@@ -40,11 +39,11 @@
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^12.1.5",
"@types/jest": "^28.1.4",
"@types/node": "^18.0.0",
"@types/node": "^18.0.1",
"@types/react": "^16.14.28",
"@types/react-dom": "^16.9.16",
"@typescript-eslint/eslint-plugin": "^5.30.3",
"@typescript-eslint/parser": "^5.30.3",
"@typescript-eslint/eslint-plugin": "^5.30.4",
"@typescript-eslint/parser": "^5.30.4",
"eslint": "^8.19.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-plugin-import": "^2.26.0",
379 changes: 191 additions & 188 deletions src/Promise.test.tsx

Large diffs are not rendered by default.

104 changes: 69 additions & 35 deletions src/Promise.ts
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;
};
69 changes: 69 additions & 0 deletions src/Unhook.test.tsx
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);
}));
});
32 changes: 32 additions & 0 deletions src/Unhook.ts
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];
};
41 changes: 41 additions & 0 deletions src/Utils.test.ts
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);
});
});
3 changes: 3 additions & 0 deletions test/index.ts
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);
});

0 comments on commit a97bb0f

Please sign in to comment.