Skip to content

Commit

Permalink
Restrict modals to functional components
Browse files Browse the repository at this point in the history
  • Loading branch information
mpontus committed Jul 12, 2019
1 parent 887572f commit 4f2e15c
Show file tree
Hide file tree
Showing 3 changed files with 81 additions and 15 deletions.
2 changes: 1 addition & 1 deletion src/ModalContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React from "react";
*
* This is what gets passed to useModal as the first argument.
*/
export type ModalType = React.ComponentType<any>;
export type ModalType = React.FunctionComponent<any>;

/**
* The shape of the modal context
Expand Down
48 changes: 47 additions & 1 deletion src/__tests__/useModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ describe("multiple modals", () => {

describe("calling useModal without ModalProvider", () => {
class ErrorBoundary extends React.Component {
static getDerivedStateFromError() {}

componentDidCatch() {}

render() {
Expand All @@ -177,6 +179,50 @@ describe("calling useModal without ModalProvider", () => {
);
flushEffects();

expect(catchError).toHaveBeenCalledTimes(1);
expect(catchError).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringMatching(
/Attempted to call useModal outside of modal context/
)
})
);
});
});

describe("calling useModal with class component", () => {
class Modal extends React.Component {
render() {
return <div>Modal content</div>;
}
}

const App = () => {
useModal(Modal as any);

return null;
};

beforeEach(() => {
jest.spyOn(console, "error");
(global.console.error as any).mockImplementation(() => {});
});

afterEach(() => {
(global.console.error as any).mockRestore();
});

it("should throw an error", () => {
const catchError = jest.fn((e: Event) => e.preventDefault());
window.addEventListener("error", catchError);

expect(() => {
renderWithProvider(<App />);
}).toThrowError(
expect.objectContaining({
message: expect.stringMatching(
/Only stateless components can be used as an argument to useModal/
)
})
);
});
});
46 changes: 33 additions & 13 deletions src/useModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,33 +16,53 @@ const generateModalKey = (() => {
return () => `${++count}`;
})();

/**
* Check whether the argument is a stateless component.
*
* We take advantage of the stateless nature of functional components to be
* inline the rendering of the modal component as part of another immutable
* component.
*
* This is necessary for allowing the modal to update based on the inputs passed
* as the second argument to useModal without unmounting the previous version of
* the modal component.
*/
const isFunctionalComponent = (Component: Function) => {
const prototype = Component.prototype;

return !prototype || !prototype.isReactComponent;
};

/**
* React hook for showing modal windows
*/
export const useModal = (
component: ModalType,
inputs: any[] = []
): [ShowModal, HideModal] => {
if (!isFunctionalComponent(component)) {
throw new Error(
"Only stateless components can be used as an argument to useModal. You have probably passed a class component where a function was expected."
);
}

const key = useMemo(generateModalKey, []);
const modal = useMemo(() => component, inputs);
const context = useContext(ModalContext);
const [isShown, setShown] = useState<boolean>(false);
const showModal = useCallback(() => setShown(true), []);
const hideModal = useCallback(() => setShown(false), []);

useEffect(
() => {
if (isShown) {
context.showModal(key, modal);
} else {
context.hideModal(key);
}

// Hide modal when parent component unmounts
return () => context.hideModal(key);
},
[modal, isShown]
);
useEffect(() => {
if (isShown) {
context.showModal(key, modal);
} else {
context.hideModal(key);
}

// Hide modal when parent component unmounts
return () => context.hideModal(key);
}, [modal, isShown]);

return [showModal, hideModal];
};

0 comments on commit 4f2e15c

Please sign in to comment.