diff --git a/src/components/Elements.test.tsx b/src/components/Elements.test.tsx index f8a6be2..4c0cc0b 100644 --- a/src/components/Elements.test.tsx +++ b/src/components/Elements.test.tsx @@ -53,9 +53,7 @@ describe('Elements', () => { expect(mockStripe.elements).toHaveBeenCalledTimes(1); }); - // TODO(christopher): support Strict Mode first - // eslint-disable-next-line jest/no-disabled-tests - test.skip('only creates elements once in Strict Mode', () => { + test('only creates elements once in Strict Mode', () => { const TestComponent = () => { const _ = useElements(); return
; @@ -83,39 +81,39 @@ describe('Elements', () => { }); test('provides elements and stripe with the ElementsConsumer component', () => { - expect.assertions(2); + const spy = jest.fn() render( {(ctx) => { - expect(ctx.elements).toBe(mockElements); - expect(ctx.stripe).toBe(mockStripe); - + spy(ctx) return null; }} ); + + expect(spy).toBeCalledWith({ stripe: mockStripe, elements: mockElements }); }); test('provides elements and stripe with the ElementsConsumer component in Strict Mode', () => { - expect.assertions(2); + const spy = jest.fn() render( - - - {(ctx) => { - expect(ctx.elements).toBe(mockElements); - expect(ctx.stripe).toBe(mockStripe); - - return null; - }} - - + + + {(ctx) => { + spy(ctx) + return null; + }} + + ); + + expect(spy).toBeCalledWith({ stripe: mockStripe, elements: mockElements }); }); test('provides given stripe instance on mount', () => { @@ -152,6 +150,30 @@ describe('Elements', () => { expect(result.current).toBe(mockElements); }); + test('allows a transition from null to a valid Stripe object in StrictMode', async () => { + let stripeProp: any = null; + const spy = jest.fn(); + const TestComponent = () => ( + + + + {(ctx) => { + spy(ctx) + return null; + }} + + + + ); + + const {rerender} = render(); + expect(spy).toBeCalledWith({stripe: null, elements: null}); + + stripeProp = mockStripe; + rerender(); + expect(spy).toBeCalledWith({stripe: mockStripe, elements: mockElements}); + }); + test('works with a Promise resolving to a valid Stripe object', async () => { const wrapper = ({children}: any) => ( {children} @@ -225,9 +247,7 @@ describe('Elements', () => { expect(mockStripe.elements).toHaveBeenCalledWith({foo: 'foo'}); }); - // TODO(christopher): support Strict Mode first - // eslint-disable-next-line jest/no-disabled-tests - test.skip('does not allow updates to options after the Stripe Promise is set in StrictMode', async () => { + test('does not allow updates to options after the Stripe Promise is set in StrictMode', async () => { // Silence console output so test output is less noisy consoleWarn.mockImplementation(() => {}); diff --git a/src/components/Elements.tsx b/src/components/Elements.tsx index ad6c004..4693fc6 100644 --- a/src/components/Elements.tsx +++ b/src/components/Elements.tsx @@ -6,8 +6,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import {isEqual} from '../utils/isEqual'; -import {usePrevious} from '../utils/usePrevious'; -import {isStripe, isPromise} from '../utils/guards'; +import {usePromiseResolver} from '../utils/usePromiseResolver'; +import {isStripe} from '../utils/guards'; const INVALID_STRIPE_ERROR = 'Invalid prop `stripe` supplied to `Elements`. We recommend using the `loadStripe` utility from `@stripe/stripe-js`. See https://stripe.com/docs/stripe-js/react#elements-props-stripe for details.'; @@ -23,28 +23,6 @@ const validateStripe = (maybeStripe: unknown): null | stripeJs.Stripe => { throw new Error(INVALID_STRIPE_ERROR); }; -type ParsedStripeProp = - | {tag: 'empty'} - | {tag: 'sync'; stripe: stripeJs.Stripe} - | {tag: 'async'; stripePromise: Promise}; - -const parseStripeProp = (raw: unknown): ParsedStripeProp => { - if (isPromise(raw)) { - return { - tag: 'async', - stripePromise: Promise.resolve(raw).then(validateStripe), - }; - } - - const stripe = validateStripe(raw); - - if (stripe === null) { - return {tag: 'empty'}; - } - - return {tag: 'sync', stripe}; -}; - interface ElementsContextValue { elements: stripeJs.StripeElements | null; stripe: stripeJs.Stripe | null; @@ -99,77 +77,55 @@ interface PrivateElementsProps { * * @docs https://stripe.com/docs/stripe-js/react#elements-provider */ -export const Elements: FunctionComponent = ({ - stripe: rawStripeProp, - options, - children, -}: PrivateElementsProps) => { - const final = React.useRef(false); - const isMounted = React.useRef(true); - const parsed = React.useMemo(() => parseStripeProp(rawStripeProp), [ - rawStripeProp, - ]); - const [ctx, setContext] = React.useState(() => ({ - stripe: null, - elements: null, - })); - - const prevStripe = usePrevious(rawStripeProp); - const prevOptions = usePrevious(options); - if (prevStripe !== null) { - if (prevStripe !== rawStripeProp) { +export const Elements: FunctionComponent = (props: PrivateElementsProps) => { + const { children } = props + + if (props.stripe === undefined) throw new Error(INVALID_STRIPE_ERROR); + + const [inputs, setInputs] = React.useState({ rawStripe: props.stripe, options: props.options }) + React.useEffect(() => { + const { rawStripe, options } = inputs + const { stripe: nextRawStripe, options: nextOptions } = props + + const canUpdate = rawStripe === null + const hasRawStripeChanged = rawStripe !== nextRawStripe + const hasOptionsChanged = !isEqual(options, nextOptions) + + if (hasRawStripeChanged && !canUpdate) { console.warn( 'Unsupported prop change on Elements: You cannot change the `stripe` prop after setting it.' ); } - if (!isEqual(options, prevOptions)) { + + if (hasOptionsChanged && !canUpdate) { console.warn( 'Unsupported prop change on Elements: You cannot change the `options` prop after setting the `stripe` prop.' ); } - } - if (!final.current) { - if (parsed.tag === 'sync') { - final.current = true; - setContext({ - stripe: parsed.stripe, - elements: parsed.stripe.elements(options), - }); - } + const nextInputs = { rawStripe: nextRawStripe, options: nextOptions } + if (hasRawStripeChanged && canUpdate) setInputs(nextInputs) + }, [inputs, props]) - if (parsed.tag === 'async') { - final.current = true; - parsed.stripePromise.then((stripe) => { - if (stripe && isMounted.current) { - // Only update Elements context if the component is still mounted - // and stripe is not null. We allow stripe to be null to make - // handling SSR easier. - setContext({ - stripe, - elements: stripe.elements(options), - }); - } - }); - } - } + const [maybeStripe = null] = usePromiseResolver(inputs.rawStripe) + const stripe = validateStripe(maybeStripe) + const [elements, setElements] = React.useState(null); React.useEffect(() => { - return (): void => { - isMounted.current = false; - }; - }, []); + if (stripe) setElements(stripe.elements(inputs.options)) + }, [stripe, inputs.options]) React.useEffect(() => { - const anyStripe: any = ctx.stripe; + const anyStripe: any = stripe; if (!anyStripe || !anyStripe._registerWrapper) { return; } anyStripe._registerWrapper({name: 'react-stripe-js', version: _VERSION}); - }, [ctx.stripe]); + }, [stripe]); + const ctx: ElementsContextValue = { stripe, elements } return ( {children} ); diff --git a/src/utils/usePrevious.test.tsx b/src/utils/usePrevious.test.tsx deleted file mode 100644 index 0440b42..0000000 --- a/src/utils/usePrevious.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import {renderHook} from '@testing-library/react-hooks'; - -import {usePrevious} from './usePrevious'; - -describe('usePrevious', () => { - it('returns the initial value if it has not yet been changed', () => { - const {result} = renderHook(() => usePrevious('foo')); - - expect(result.current).toEqual('foo'); - }); - - it('returns the previous value after the it has been changed', () => { - let val = 'foo'; - const {result, rerender} = renderHook(() => usePrevious(val)); - - expect(result.current).toEqual('foo'); - - val = 'bar'; - rerender(); - expect(result.current).toEqual('foo'); - - val = 'baz'; - rerender(); - expect(result.current).toEqual('bar'); - - val = 'buz'; - rerender(); - expect(result.current).toEqual('baz'); - }); -}); diff --git a/src/utils/usePrevious.ts b/src/utils/usePrevious.ts deleted file mode 100644 index d278086..0000000 --- a/src/utils/usePrevious.ts +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; - -export const usePrevious = (value: T): T => { - const ref = React.useRef(value); - - React.useEffect(() => { - ref.current = value; - }, [value]); - - return ref.current; -}; diff --git a/src/utils/usePromiseResolver.test.tsx b/src/utils/usePromiseResolver.test.tsx new file mode 100644 index 0000000..bbe3dc5 --- /dev/null +++ b/src/utils/usePromiseResolver.test.tsx @@ -0,0 +1,60 @@ +import {renderHook, act} from '@testing-library/react-hooks'; +import {usePromiseResolver} from './usePromiseResolver'; +import { mockStripe } from '../../test/mocks'; + +const createImperativePromise = (): [Promise, (value?: unknown) => Promise, (reason?: any) => Promise] => { + let resolveFn: (value?: unknown) => Promise = () => Promise.resolve() + let rejectFn: (reason?: any) => Promise = () => Promise.resolve() + + const promise = new Promise((resolve, reject) => { + const createVoidPromise = () => promise.then(() => undefined, () => undefined) + + resolveFn = (value) => { + resolve(value) + return createVoidPromise() + } + + rejectFn = (reason) => { + reject(reason) + return createVoidPromise() + } + }) + + return [promise, resolveFn, rejectFn] +} + +describe('usePromiseResolver', () => { + let stripe: ReturnType; + + beforeEach(() => { + stripe = mockStripe(); + }) + + it('returns resolved on mount when not promise given', () => { + const {result} = renderHook(() => usePromiseResolver(stripe)); + expect(result.current).toEqual([stripe, undefined, 'resolved']) + }); + + it('returns pending on mount when promise given', () => { + const [promise] = createImperativePromise() + const {result} = renderHook(() => usePromiseResolver(promise)); + expect(result.current).toEqual([undefined, undefined, 'pending']) + }); + + it('returns resolved when given promise resolved', async () => { + const [promise, resolve] = createImperativePromise() + const {result} = renderHook(() => usePromiseResolver(promise)); + + await act(() => resolve(stripe)) + expect(result.current).toEqual([stripe, undefined, 'resolved']) + }); + + it('returns rejected when given promise rejected', async () => { + const [promise,, reject] = createImperativePromise() + const {result} = renderHook(() => usePromiseResolver(promise)); + + const error = new Error('Something went wrong') + await act(() => reject(error)) + expect(result.current).toEqual([undefined, error, 'rejected']) + }); +}); diff --git a/src/utils/usePromiseResolver.ts b/src/utils/usePromiseResolver.ts new file mode 100644 index 0000000..c9019ff --- /dev/null +++ b/src/utils/usePromiseResolver.ts @@ -0,0 +1,51 @@ +import React from 'react'; +import {isPromise} from '../utils/guards'; + +type PromisePending = [undefined, undefined, 'pending']; +type PromiseResolved = [T, undefined, 'resolved']; +type PromiseRejected = [undefined, any, 'rejected']; +type PromiseState = PromisePending | PromiseResolved | PromiseRejected; + +const createPending = (): PromisePending => [undefined, undefined, 'pending']; + +const createResolved = (value: T): PromiseResolved => [ + value, + undefined, + 'resolved', +]; + +const createRejected = (reason: any): PromiseRejected => [ + undefined, + reason, + 'rejected', +]; + +export const usePromiseResolver = ( + mayBePromise: T | PromiseLike +): PromiseState => { + const [state, setState] = React.useState>(() => + isPromise(mayBePromise) ? createPending() : createResolved(mayBePromise) + ); + + React.useEffect(() => { + if (!isPromise(mayBePromise)) return setState(createResolved(mayBePromise)); + + let isMounted = true; + + setState(createPending()); + mayBePromise + .then( + (resolved) => createResolved(resolved), + (error) => createRejected(error) + ) + .then((nextState) => { + if (isMounted) setState(nextState); + }); + + return () => { + isMounted = false; + }; + }, [mayBePromise]); + + return state; +};