diff --git a/examples/WebpackApp/src/Alert.tsx b/examples/WebpackApp/src/Alert.tsx index 28f13ea..07deac4 100644 --- a/examples/WebpackApp/src/Alert.tsx +++ b/examples/WebpackApp/src/Alert.tsx @@ -70,8 +70,9 @@ const AlertPopup: React.FunctionComponent = ({ ); +const props = {}; export const useAlert = () => { - const enqueueAlert = usePopupsFactory(AlertPopup, {}, SnackbarGroup); + const enqueueAlert = usePopupsFactory(AlertPopup, props, SnackbarGroup); const showAlert = useCallback( (message: string, severity: AlertColor) => { diff --git a/examples/WebpackApp/src/App.tsx b/examples/WebpackApp/src/App.tsx index bf4368f..4252ecb 100644 --- a/examples/WebpackApp/src/App.tsx +++ b/examples/WebpackApp/src/App.tsx @@ -2,13 +2,17 @@ import React, { useCallback } from 'react'; import { usePopup, useResponsePopup } from 'reactive-popups'; import { AlertTrigger } from './Alert'; -import { ConfirmPopup } from './ConfirmPopup'; -import { FalsyResponsePopup } from './FalsyResponsePopup'; +import { ConfirmPopup, ConfirmPopupProps } from './ConfirmPopup'; import { MuiPopup } from './MuiPopup'; +import { TestComponent } from './TestComponent'; import { DefaultPopupGroup } from '.'; export const App = () => { - const confirm = useResponsePopup(ConfirmPopup, {}, DefaultPopupGroup); + const confirm = useResponsePopup( + ConfirmPopup, + {}, + DefaultPopupGroup + ); const [open, close] = usePopup( MuiPopup, { content: 'Single popup example using usePopup hook' }, @@ -40,6 +44,7 @@ export const App = () => { {/* */} + ); }; diff --git a/examples/WebpackApp/src/ConfirmPopup.tsx b/examples/WebpackApp/src/ConfirmPopup.tsx index a496aa4..0ab093e 100644 --- a/examples/WebpackApp/src/ConfirmPopup.tsx +++ b/examples/WebpackApp/src/ConfirmPopup.tsx @@ -22,7 +22,7 @@ export const ConfirmPopup = ({ message }: ConfirmPopupProps) => { setOpen(false); }, []); - const { reject, resolve, unmount } = useResponseHandler(close); + const { reject, resolve, unmount } = useResponseHandler(close); return ( { + const show = useAlert(); + + useEffect(() => { + show('Show this message on mount!', 'success'); + }, [show]); + + return
test
; +}; diff --git a/src/constants.ts b/src/constants.ts deleted file mode 100644 index 6e8fe03..0000000 --- a/src/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const PROMISE_NOT_SETTLED = - 'Promise from ResponsePopup was not settled (memory leak).'; -export const RESPONSE_HANDLER_BAD_USE = - 'useResponseHandler hook must be used only in popups created with useResponsePopup.'; -export const CLOSE_HANDLER_BAD_USE = - 'useCloseHandler hook must be used only in popups created with usePopupsFactory.'; -export const CLOSE_NOT_IMPLEMENTED = 'Close function was not implemented.'; diff --git a/src/hooks/useCloseHandler.ts b/src/hooks/useCloseHandler.ts index 9704e3d..6812abc 100644 --- a/src/hooks/useCloseHandler.ts +++ b/src/hooks/useCloseHandler.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect } from 'react'; import { usePopupsContext } from './usePopupsContext'; -import { CLOSE_HANDLER_BAD_USE } from '../constants'; import { isDefaultPopup } from '../types/DefaultPopup'; import { usePopupIdentifier } from '../utils/PopupIdentifierContext'; @@ -20,15 +19,15 @@ export const useCloseHandler = ( const popup = getPopup(popupIdentifier); if (!isDefaultPopup(popup!)) { - throw new Error(CLOSE_HANDLER_BAD_USE); + throw new Error( + 'useCloseHandler hook must be used only in popups created with usePopupsFactory.' + ); } if (close) { popup.setCloseHandler(close); } - // BUG with throwing error if getPopup is in dependency list - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [popupIdentifier, close]); + }, [popupIdentifier, close, getPopup]); return unmountPopup; }; diff --git a/src/hooks/usePopup.ts b/src/hooks/usePopup.ts index d533805..04b4abb 100644 --- a/src/hooks/usePopup.ts +++ b/src/hooks/usePopup.ts @@ -17,7 +17,7 @@ export const usePopup = ( props: Pick, group: PopupGroup ): UsePopupBag => { - const { mount, close: closePopup } = usePopupsContext(); + const { mount, close: closePopup, unmount } = usePopupsContext(); const popupIdentifier = useRef({ id: uuid(), @@ -26,15 +26,20 @@ export const usePopup = ( const open = useCallback, void>>( (omittedProps?: Omit) => { + const defaultClose = () => { + unmount(popupIdentifier.current); + }; + const popup = new DefaultPopup( PopupComponent as ComponentType<{}>, { ...props, ...omittedProps }, - popupIdentifier.current + popupIdentifier.current, + defaultClose ); mount(popup); }, - [PopupComponent, mount, props] + [PopupComponent, mount, props, unmount] ); const close = useCallback(() => { diff --git a/src/hooks/usePopupsBag.ts b/src/hooks/usePopupsBag.ts index 6564df0..54508d2 100644 --- a/src/hooks/usePopupsBag.ts +++ b/src/hooks/usePopupsBag.ts @@ -1,46 +1,33 @@ -import { useCallback, useReducer } from 'react'; +import { useCallback, useReducer, useRef } from 'react'; -import { PopupGroup } from '../components/PopupGroup'; import { Popup } from '../types/Popup'; import { PopupIdentifier } from '../types/PopupIdentifier'; import { PopupsBag } from '../types/PopupsBag'; -import { popupsReducer } from '../utils/popupsReducer'; +import { ActionType, popupsReducer } from '../utils/popupsReducer'; export const usePopupsBag = (): PopupsBag => { const [popupsState, dispatch] = useReducer(popupsReducer, { popups: {} }); - const getPopupsByGroup = useCallback( - (group: PopupGroup) => { - if (!popupsState.popups[group.groupId]) { - return []; - } + const popupsStateRef = useRef(popupsState); + popupsStateRef.current = popupsState; - return Object.values(popupsState.popups[group.groupId]); - }, - [popupsState] - ); + const getPopup = useCallback(({ groupId, id }: PopupIdentifier) => { + const popups = popupsStateRef.current.popups; - const getPopup = useCallback( - ({ groupId, id }: PopupIdentifier) => { - if ( - !popupsState.popups[groupId] || - !popupsState.popups[groupId][id] - ) { - return null; - } + if (!popups[groupId] || !popups[groupId][id]) { + return null; + } - return popupsState.popups[groupId][id]; - }, - [popupsState] - ); + return popups[groupId][id]; + }, []); const unmount = useCallback((popupIdentifier: PopupIdentifier) => { - dispatch({ type: 'unmount', payload: { popupIdentifier } }); + dispatch({ type: ActionType.UNMOUNT, payload: { popupIdentifier } }); }, []); const mount = useCallback(

(popup: Popup

) => { dispatch({ - type: 'mount', + type: ActionType.MOUNT, payload: { popup: popup as unknown as Popup, }, @@ -60,8 +47,8 @@ export const usePopupsBag = (): PopupsBag => { return { mount, unmount, - getPopupsByGroup, getPopup, close, + popupsState, }; }; diff --git a/src/hooks/usePopupsByGroup.ts b/src/hooks/usePopupsByGroup.ts index 8d14dbb..35324d3 100644 --- a/src/hooks/usePopupsByGroup.ts +++ b/src/hooks/usePopupsByGroup.ts @@ -3,9 +3,11 @@ import { PopupGroup } from '../components/PopupGroup'; import { Popup } from '../types/Popup'; export const usePopupsByGroup = (group: PopupGroup): Popup[] => { - const { getPopupsByGroup } = usePopupsContext(); + const { popupsState } = usePopupsContext(); - const popups = getPopupsByGroup(group); + if (!popupsState.popups[group.groupId]) { + return []; + } - return popups; + return Object.values(popupsState.popups[group.groupId]); }; diff --git a/src/hooks/usePopupsFactory.ts b/src/hooks/usePopupsFactory.ts index d54b09d..f779148 100644 --- a/src/hooks/usePopupsFactory.ts +++ b/src/hooks/usePopupsFactory.ts @@ -4,6 +4,7 @@ import { usePopupsContext } from './usePopupsContext'; import { PopupGroup } from '../components/PopupGroup'; import { DefaultPopup } from '../types/DefaultPopup'; import { OptionalParamFunction } from '../types/OptionalParamFunction'; +import { PopupIdentifier } from '../types/PopupIdentifier'; import { uuid } from '../utils/uuid'; export type UsePopupsFactoryBag = OptionalParamFunction< @@ -16,31 +17,38 @@ export const usePopupsFactory = ( props: Pick, group: PopupGroup ): UsePopupsFactoryBag => { - const { mount, close } = usePopupsContext(); + const { mount, unmount } = usePopupsContext(); const create = useCallback( (omittedProps?: Omit) => { const id = uuid(); + const popupIdentifier: PopupIdentifier = { + id, + groupId: group.groupId, + }; + + const defaultClose = () => { + unmount(popupIdentifier); + }; + const popup = new DefaultPopup( PopupComponent, { ...props, ...omittedProps, } as P, - { - id, - groupId: group.groupId, - } + popupIdentifier, + defaultClose ); - const identifier = mount

(popup); + mount

(popup); return () => { - close(identifier); + unmount(popupIdentifier); }; }, - [mount, PopupComponent, props, group, close] + [group.groupId, PopupComponent, props, mount, unmount] ); return create; diff --git a/src/hooks/useResponseHandler.ts b/src/hooks/useResponseHandler.ts index b2da60b..049badd 100644 --- a/src/hooks/useResponseHandler.ts +++ b/src/hooks/useResponseHandler.ts @@ -1,17 +1,18 @@ import { useCallback, useEffect, useRef } from 'react'; import { usePopupsContext } from './usePopupsContext'; -import { PROMISE_NOT_SETTLED, RESPONSE_HANDLER_BAD_USE } from '../constants'; import { isResponsePopup, ResponsePopup } from '../types/ResponsePopup'; import { usePopupIdentifier } from '../utils/PopupIdentifierContext'; -export type ResponseHandler = { - resolve: (value: unknown | PromiseLike) => void; +export type ResponseHandler = { + resolve: (value: R | PromiseLike) => void; reject: (reason?: unknown) => void; unmount: () => void; }; -export const useResponseHandler = (close: () => void): ResponseHandler => { +export const useResponseHandler = ( + close: () => void +): ResponseHandler => { const { getPopup, close: closePopup, @@ -19,10 +20,10 @@ export const useResponseHandler = (close: () => void): ResponseHandler => { } = usePopupsContext(); const popupIdentifier = usePopupIdentifier(); - const popupRef = useRef | null>(null); + const popupRef = useRef | null>(null); const resolve = useCallback( - (value: unknown) => { + (value: R | PromiseLike) => { popupRef.current!.resolve!(value); closePopup(popupIdentifier); }, @@ -38,8 +39,10 @@ export const useResponseHandler = (close: () => void): ResponseHandler => { ); const unmount = useCallback(() => { - if (!popupRef.current!.isSettled!) { - throw new Error(PROMISE_NOT_SETTLED); + if (!popupRef.current!.isSettled) { + throw new Error( + 'Promise from ResponsePopup was not settled (memory leak).' + ); } unmountPopup(popupIdentifier); @@ -49,7 +52,9 @@ export const useResponseHandler = (close: () => void): ResponseHandler => { const popup = getPopup(popupIdentifier); if (!isResponsePopup(popup!)) { - throw new Error(RESPONSE_HANDLER_BAD_USE); + throw new Error( + 'useResponseHandler hook must be used only in popups created with useResponsePopup.' + ); } popup.setCloseHandler(close); diff --git a/src/hooks/useResponsePopup.ts b/src/hooks/useResponsePopup.ts index eb3afc1..0992a80 100644 --- a/src/hooks/useResponsePopup.ts +++ b/src/hooks/useResponsePopup.ts @@ -1,8 +1,9 @@ -import { ComponentType, useCallback } from 'react'; +import { ComponentType, useCallback, useRef } from 'react'; import { usePopupsContext } from './usePopupsContext'; import { PopupGroup } from '../components/PopupGroup'; import { OptionalParamFunction } from '../types/OptionalParamFunction'; +import { PopupIdentifier } from '../types/PopupIdentifier'; import { ResponsePopup } from '../types/ResponsePopup'; import { uuid } from '../utils/uuid'; @@ -17,11 +18,19 @@ export const useResponsePopup = ( props: Pick, group: PopupGroup ): UseResponsePopupBag => { - const { mount } = usePopupsContext(); + const { mount, unmount } = usePopupsContext(); + + const popupIdentifierRef = useRef({ + id: uuid(), + groupId: group.groupId, + }); + + const defaultClose = useCallback(() => { + unmount(popupIdentifierRef.current); + }, [unmount]); const waitResponse = useCallback( (omittedProps?: Omit) => { - const id = uuid(); let popup: ResponsePopup | null = null; const promise = new Promise((resolve, reject) => { @@ -31,10 +40,8 @@ export const useResponsePopup = ( ...props, ...omittedProps, } as P, - { - id, - groupId: group.groupId, - }, + popupIdentifierRef.current, + defaultClose, resolve, reject ); @@ -48,7 +55,7 @@ export const useResponsePopup = ( return promise; }, - [PopupComponent, group, mount, props] + [PopupComponent, defaultClose, mount, props] ); return waitResponse; diff --git a/src/hooks/useUnmount.ts b/src/hooks/useUnmount.ts new file mode 100644 index 0000000..b5a9231 --- /dev/null +++ b/src/hooks/useUnmount.ts @@ -0,0 +1,7 @@ +import { usePopupsContext } from './usePopupsContext'; + +export const useUnmount = () => { + const { unmount } = usePopupsContext(); + + return unmount; +}; diff --git a/src/index.ts b/src/index.ts index 7797883..95d977e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export * from './hooks/useCloseHandler'; export * from './hooks/useResponseHandler'; export * from './hooks/usePopupsByGroup'; export * from './hooks/usePopup'; +export * from './hooks/useUnmount'; // types export * from './types/Popup'; diff --git a/src/types/DefaultPopup.ts b/src/types/DefaultPopup.ts index f1fda87..7ab3889 100644 --- a/src/types/DefaultPopup.ts +++ b/src/types/DefaultPopup.ts @@ -11,9 +11,10 @@ export class DefaultPopup

extends Popup

{ constructor( PopupComponent: ComponentType

, props: P, - popupIdentifier: PopupIdentifier + popupIdentifier: PopupIdentifier, + close: () => void | Promise ) { - super(PopupComponent, props, popupIdentifier); + super(PopupComponent, props, popupIdentifier, close); } } diff --git a/src/types/Popup.ts b/src/types/Popup.ts index 1328744..e494c69 100644 --- a/src/types/Popup.ts +++ b/src/types/Popup.ts @@ -1,19 +1,15 @@ import { ComponentType } from 'react'; import { PopupIdentifier } from './PopupIdentifier'; -import { CLOSE_NOT_IMPLEMENTED } from '../constants'; export abstract class Popup

{ constructor( public PopupComponent: ComponentType

, public props: P, - public popupIdentifier: PopupIdentifier + public popupIdentifier: PopupIdentifier, + public close: () => void | Promise ) {} - public close: () => void | Promise = () => { - throw new Error(CLOSE_NOT_IMPLEMENTED); - }; - public setCloseHandler: (close: () => void | Promise) => void = ( close ) => { diff --git a/src/types/PopupsBag.ts b/src/types/PopupsBag.ts index 5347893..50e9341 100644 --- a/src/types/PopupsBag.ts +++ b/src/types/PopupsBag.ts @@ -1,13 +1,12 @@ import { Popup } from './Popup'; import { PopupIdentifier } from './PopupIdentifier'; -import { PopupGroup } from '../components/PopupGroup'; +import { PopupsState } from '../utils/popupsReducer'; export type PopupsBag = { mount:

(popup: Popup

) => PopupIdentifier; unmount: (popupIdentifier: PopupIdentifier) => void; + close: (popupIdentifier: PopupIdentifier) => void; - getPopupsByGroup: (group: PopupGroup) => Array>; + popupsState: PopupsState; getPopup: (popupIdentifier: PopupIdentifier) => Popup | null; - - close: (popupIdentifier: PopupIdentifier) => void; }; diff --git a/src/types/ResponsePopup.ts b/src/types/ResponsePopup.ts index e479cb5..f50ee23 100644 --- a/src/types/ResponsePopup.ts +++ b/src/types/ResponsePopup.ts @@ -12,10 +12,11 @@ export class ResponsePopup extends Popup

{ PopupComponent: ComponentType

, props: P, popupIdentifier: PopupIdentifier, + close: () => void | Promise, public resolve: (value: R | PromiseLike) => void, public reject: (reason?: unknown) => void ) { - super(PopupComponent, props, popupIdentifier); + super(PopupComponent, props, popupIdentifier, close); } } diff --git a/src/utils/popupsReducer.ts b/src/utils/popupsReducer.ts index 90358dc..4f1d285 100644 --- a/src/utils/popupsReducer.ts +++ b/src/utils/popupsReducer.ts @@ -2,15 +2,20 @@ import { Popup } from '../types/Popup'; import { PopupIdentifier } from '../types/PopupIdentifier'; import { PopupsRegistry } from '../types/PopupsRegistry'; +export enum ActionType { + MOUNT, + UNMOUNT, +} + type MountAction = { - type: 'mount'; + type: ActionType.MOUNT; payload: { popup: Popup; }; }; type UnmountAction = { - type: 'unmount'; + type: ActionType.UNMOUNT; payload: { popupIdentifier: PopupIdentifier; }; @@ -18,12 +23,14 @@ type UnmountAction = { export type PopupsAction = MountAction | UnmountAction; +export type PopupsState = { popups: PopupsRegistry }; + export const popupsReducer = ( - { popups }: { popups: PopupsRegistry }, + { popups }: PopupsState, action: PopupsAction ) => { switch (action.type) { - case 'mount': { + case ActionType.MOUNT: { const { popup } = action.payload; const { popupIdentifier: { groupId, id }, @@ -40,7 +47,7 @@ export const popupsReducer = ( }; } - case 'unmount': { + case ActionType.UNMOUNT: { const { groupId, id } = action.payload.popupIdentifier; delete popups[groupId][id]; @@ -51,7 +58,7 @@ export const popupsReducer = ( } default: { - throw new Error(); + throw new Error('Action type is not valid'); } } }; diff --git a/test/utils/popupsReducer.test.tsx b/test/utils/popupsReducer.test.tsx index 4e8c42f..81f151e 100644 --- a/test/utils/popupsReducer.test.tsx +++ b/test/utils/popupsReducer.test.tsx @@ -5,7 +5,7 @@ import { createPopupGroup } from '../../src/components/PopupGroup'; import { DefaultPopup } from '../../src/types/DefaultPopup'; import { Popup } from '../../src/types/Popup'; import { PopupIdentifier } from '../../src/types/PopupIdentifier'; -import { popupsReducer } from '../../src/utils/popupsReducer'; +import { ActionType, popupsReducer } from '../../src/utils/popupsReducer'; import { uuid } from '../../src/utils/uuid'; const group = createPopupGroup(); @@ -27,11 +27,18 @@ describe('State reducer of popups', () => { const [state, dispatch] = result.current; const popupIdentifier = getNewPopupIdentifier(); - const popup = new DefaultPopup(PopupComponent, {}, popupIdentifier); + const popup = new DefaultPopup( + PopupComponent, + {}, + popupIdentifier, + () => { + // do nothing + } + ); act(() => { dispatch({ - type: 'mount', + type: ActionType.MOUNT, payload: { popup, }, @@ -60,7 +67,7 @@ describe('State reducer of popups', () => { act(() => { dispatch({ - type: 'unmount', + type: ActionType.UNMOUNT, payload: { popupIdentifier: { groupId: group.groupId,