diff --git a/src/components/createElementComponent.test.tsx b/src/components/createElementComponent.test.tsx index 5ee650d..a712e08 100644 --- a/src/components/createElementComponent.test.tsx +++ b/src/components/createElementComponent.test.tsx @@ -19,13 +19,21 @@ describe('createElementComponent', () => { let mockElements: any; let mockElement: any; let mockCartElementContext: any; - - let simulateElementsEvents: Record; - let simulateOn: any; - let simulateOff: any; - const simulateEvent = (event: string, ...args: any[]) => { - simulateElementsEvents[event].forEach((fn) => fn(...args)); - }; + let simulateChange: any; + let simulateBlur: any; + let simulateFocus: any; + let simulateEscape: any; + let simulateReady: any; + let simulateClick: any; + let simulateLoadError: any; + let simulateLoaderStart: any; + let simulateNetworksChange: any; + let simulateCheckout: any; + let simulateLineItemClick: any; + let simulateConfirm: any; + let simulateCancel: any; + let simulateShippingAddressChange: any; + let simulateShippingRateChange: any; beforeEach(() => { mockStripe = mocks.mockStripe(); @@ -34,23 +42,57 @@ describe('createElementComponent', () => { mockStripe.elements.mockReturnValue(mockElements); mockElements.create.mockReturnValue(mockElement); jest.spyOn(React, 'useLayoutEffect'); - - simulateElementsEvents = {}; - simulateOn = jest.fn((event, fn) => { - simulateElementsEvents[event] = [ - ...(simulateElementsEvents[event] || []), - fn, - ]; + mockElement.on = jest.fn((event, fn) => { + switch (event) { + case 'change': + simulateChange = fn; + break; + case 'blur': + simulateBlur = fn; + break; + case 'focus': + simulateFocus = fn; + break; + case 'escape': + simulateEscape = fn; + break; + case 'ready': + simulateReady = fn; + break; + case 'click': + simulateClick = fn; + break; + case 'loaderror': + simulateLoadError = fn; + break; + case 'loaderstart': + simulateLoaderStart = fn; + break; + case 'networkschange': + simulateNetworksChange = fn; + break; + case 'checkout': + simulateCheckout = fn; + break; + case 'lineitemclick': + simulateLineItemClick = fn; + break; + case 'confirm': + simulateConfirm = fn; + break; + case 'cancel': + simulateCancel = fn; + break; + case 'shippingaddresschange': + simulateShippingAddressChange = fn; + break; + case 'shippingratechange': + simulateShippingRateChange = fn; + break; + default: + throw new Error('TestSetupError: Unexpected event registration.'); + } }); - simulateOff = jest.fn((event, fn) => { - simulateElementsEvents[event] = simulateElementsEvents[event].filter( - (previouslyAddedFn) => previouslyAddedFn !== fn - ); - }); - - mockElement.on = simulateOn; - mockElement.off = simulateOff; - mockCartElementContext = mocks.mockCartElementContext(); jest .spyOn(ElementsModule, 'useCartElementContextWithUseCase') @@ -204,9 +246,6 @@ describe('createElementComponent', () => { ); expect(mockElements.create).toHaveBeenCalledWith('card', options); - - expect(simulateOn).not.toBeCalled(); - expect(simulateOff).not.toBeCalled(); }); it('mounts the element', () => { @@ -218,9 +257,6 @@ describe('createElementComponent', () => { expect(mockElement.mount).toHaveBeenCalledWith(container.firstChild); expect(React.useLayoutEffect).toHaveBeenCalled(); - - expect(simulateOn).not.toBeCalled(); - expect(simulateOff).not.toBeCalled(); }); it('does not create and mount until Elements has been instantiated', () => { @@ -253,28 +289,6 @@ describe('createElementComponent', () => { ); }); - it('removes old event handlers when an event handler changes', () => { - const mockHandler = jest.fn(); - const mockHandler2 = jest.fn(); - const {rerender} = render( - - - - ); - - expect(simulateOn).toBeCalledWith('change', mockHandler); - expect(simulateOff).not.toBeCalled(); - - rerender( - - - - ); - - expect(simulateOff).toBeCalledWith('change', mockHandler); - expect(simulateOn).toHaveBeenLastCalledWith('change', mockHandler2); - }); - it('propagates the Element`s ready event to the current onReady prop', () => { const mockHandler = jest.fn(); const mockHandler2 = jest.fn(); @@ -289,32 +303,11 @@ describe('createElementComponent', () => { ); - const mockEvent = Symbol('ready'); - simulateEvent('ready', mockEvent); + simulateReady(); expect(mockHandler2).toHaveBeenCalledWith(mockElement); expect(mockHandler).not.toHaveBeenCalled(); }); - it('propagates the Pay Button Element`s ready event to the current onReady prop', () => { - const mockHandler = jest.fn(); - const mockHandler2 = jest.fn(); - const {rerender} = render( - - {}} /> - - ); - rerender( - - {}} /> - - ); - - const mockEvent = Symbol('ready'); - simulateEvent('ready', mockEvent); - expect(mockHandler2).toHaveBeenCalledWith(mockEvent); - expect(mockHandler).not.toHaveBeenCalled(); - }); - it('sets cart in the CartElementContext', () => { expect(mockCartElementContext.cart).toBe(null); @@ -344,58 +337,8 @@ describe('createElementComponent', () => { }, }; - simulateEvent('ready', readyEvent); - expect(mockCartElementContext.cartState).toBe(readyEvent); - - const changeEvent = { - elementType: 'cart', - id: 'cart_session_id_change', - lineItems: { - count: 1, - }, - }; - simulateEvent('change', changeEvent); - expect(mockCartElementContext.cartState).toBe(changeEvent); - - const checkoutEvent = { - elementType: 'cart', - id: 'cart_session_id_checkout', - lineItems: { - count: 2, - }, - }; - simulateEvent('checkout', checkoutEvent); - expect(mockCartElementContext.cartState).toBe(checkoutEvent); - }); - - it('sets cartState in the CartElementContext when passing in callbacks', () => { - const onReady = jest.fn(); - const onChange = jest.fn(); - const onCheckout = jest.fn(); - - render( - - - - ); - - expect(mockCartElementContext.cartState).toBe(null); - - const readyEvent = { - elementType: 'cart', - id: 'cart_session_id_ready', - lineItems: { - count: 0, - }, - }; - - simulateEvent('ready', readyEvent); + simulateReady(readyEvent); expect(mockCartElementContext.cartState).toBe(readyEvent); - expect(onReady).toBeCalledWith(readyEvent); const changeEvent = { elementType: 'cart', @@ -404,9 +347,8 @@ describe('createElementComponent', () => { count: 1, }, }; - simulateEvent('change', changeEvent); + simulateChange(changeEvent); expect(mockCartElementContext.cartState).toBe(changeEvent); - expect(onChange).toBeCalledWith(changeEvent); const checkoutEvent = { elementType: 'cart', @@ -415,9 +357,8 @@ describe('createElementComponent', () => { count: 2, }, }; - simulateEvent('checkout', checkoutEvent); + simulateCheckout(checkoutEvent); expect(mockCartElementContext.cartState).toBe(checkoutEvent); - expect(onCheckout).toBeCalledWith(checkoutEvent); }); it('propagates the Element`s change event to the current onChange prop', () => { @@ -435,7 +376,7 @@ describe('createElementComponent', () => { ); const changeEventMock = Symbol('change'); - simulateEvent('change', changeEventMock); + simulateChange(changeEventMock); expect(mockHandler2).toHaveBeenCalledWith(changeEventMock); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -454,7 +395,7 @@ describe('createElementComponent', () => { ); - simulateEvent('blur'); + simulateBlur(); expect(mockHandler2).toHaveBeenCalledWith(); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -473,7 +414,7 @@ describe('createElementComponent', () => { ); - simulateEvent('focus'); + simulateFocus(); expect(mockHandler2).toHaveBeenCalledWith(); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -492,7 +433,7 @@ describe('createElementComponent', () => { ); - simulateEvent('escape'); + simulateEscape(); expect(mockHandler2).toHaveBeenCalledWith(); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -512,7 +453,7 @@ describe('createElementComponent', () => { ); const clickEventMock = Symbol('click'); - simulateEvent('click', clickEventMock); + simulateClick(clickEventMock); expect(mockHandler2).toHaveBeenCalledWith(clickEventMock); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -532,7 +473,7 @@ describe('createElementComponent', () => { ); const loadErrorEventMock = Symbol('loaderror'); - simulateEvent('loaderror', loadErrorEventMock); + simulateLoadError(loadErrorEventMock); expect(mockHandler2).toHaveBeenCalledWith(loadErrorEventMock); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -551,7 +492,7 @@ describe('createElementComponent', () => { ); - simulateEvent('loaderstart'); + simulateLoaderStart(); expect(mockHandler2).toHaveBeenCalledWith(); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -570,7 +511,7 @@ describe('createElementComponent', () => { ); - simulateEvent('networkschange'); + simulateNetworksChange(); expect(mockHandler2).toHaveBeenCalledWith(); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -590,7 +531,7 @@ describe('createElementComponent', () => { ); const checkoutEventMock = Symbol('checkout'); - simulateEvent('checkout', checkoutEventMock); + simulateCheckout(checkoutEventMock); expect(mockHandler2).toHaveBeenCalledWith(checkoutEventMock); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -610,7 +551,7 @@ describe('createElementComponent', () => { ); const lineItemClickEventMock = Symbol('lineitemclick'); - simulateEvent('lineitemclick', lineItemClickEventMock); + simulateLineItemClick(lineItemClickEventMock); expect(mockHandler2).toHaveBeenCalledWith(lineItemClickEventMock); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -630,7 +571,7 @@ describe('createElementComponent', () => { ); const confirmEventMock = Symbol('confirm'); - simulateEvent('confirm', confirmEventMock); + simulateConfirm(confirmEventMock); expect(mockHandler2).toHaveBeenCalledWith(confirmEventMock); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -650,7 +591,7 @@ describe('createElementComponent', () => { ); const cancelEventMock = Symbol('cancel'); - simulateEvent('cancel', cancelEventMock); + simulateCancel(cancelEventMock); expect(mockHandler2).toHaveBeenCalledWith(cancelEventMock); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -676,7 +617,7 @@ describe('createElementComponent', () => { ); const shippingAddressChangeEventMock = Symbol('shippingaddresschange'); - simulateEvent('shippingaddresschange', shippingAddressChangeEventMock); + simulateShippingAddressChange(shippingAddressChangeEventMock); expect(mockHandler2).toHaveBeenCalledWith(shippingAddressChangeEventMock); expect(mockHandler).not.toHaveBeenCalled(); }); @@ -702,7 +643,7 @@ describe('createElementComponent', () => { ); const shippingRateChangeEventMock = Symbol('shippingratechange'); - simulateEvent('shippingratechange', shippingRateChangeEventMock); + simulateShippingRateChange(shippingRateChangeEventMock); expect(mockHandler2).toHaveBeenCalledWith(shippingRateChangeEventMock); expect(mockHandler).not.toHaveBeenCalled(); }); diff --git a/src/components/createElementComponent.tsx b/src/components/createElementComponent.tsx index 53132e8..abd4e8e 100644 --- a/src/components/createElementComponent.tsx +++ b/src/components/createElementComponent.tsx @@ -10,7 +10,7 @@ import { useElementsContextWithUseCase, useCartElementContextWithUseCase, } from './Elements'; -import {useAttachEvent} from '../utils/useAttachEvent'; +import {useCallbackReference} from '../utils/useCallbackReference'; import {ElementProps} from '../types'; import {usePrevious} from '../utils/usePrevious'; import { @@ -41,6 +41,8 @@ interface PrivateElementProps { options?: UnknownOptions; } +const noop = () => {}; + const capitalized = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); const createElementComponent = ( @@ -53,21 +55,21 @@ const createElementComponent = ( id, className, options = {}, - onBlur, - onFocus, - onReady, - onChange, - onEscape, - onClick, - onLoadError, - onLoaderStart, - onNetworksChange, - onCheckout, - onLineItemClick, - onConfirm, - onCancel, - onShippingAddressChange, - onShippingRateChange, + onBlur = noop, + onFocus = noop, + onReady = noop, + onChange = noop, + onEscape = noop, + onClick = noop, + onLoadError = noop, + onLoaderStart = noop, + onNetworksChange = noop, + onCheckout = noop, + onLineItemClick = noop, + onConfirm = noop, + onCancel = noop, + onShippingAddressChange = noop, + onShippingRateChange = noop, }) => { const {elements} = useElementsContextWithUseCase(`mounts <${displayName}>`); const elementRef = React.useRef(null); @@ -77,67 +79,23 @@ const createElementComponent = ( `mounts <${displayName}>` ); - // For every event where the merchant provides a callback, call element.on - // with that callback. If the merchant ever changes the callback, removes - // the old callback with element.off and then call element.on with the new one. - useAttachEvent(elementRef, 'blur', onBlur); - useAttachEvent(elementRef, 'focus', onFocus); - useAttachEvent(elementRef, 'escape', onEscape); - useAttachEvent(elementRef, 'click', onClick); - useAttachEvent(elementRef, 'loaderror', onLoadError); - useAttachEvent(elementRef, 'loaderstart', onLoaderStart); - useAttachEvent(elementRef, 'networkschange', onNetworksChange); - useAttachEvent(elementRef, 'lineitemclick', onLineItemClick); - useAttachEvent(elementRef, 'confirm', onConfirm); - useAttachEvent(elementRef, 'cancel', onCancel); - useAttachEvent( - elementRef, - 'shippingaddresschange', + const callOnReady = useCallbackReference(onReady); + const callOnBlur = useCallbackReference(onBlur); + const callOnFocus = useCallbackReference(onFocus); + const callOnClick = useCallbackReference(onClick); + const callOnChange = useCallbackReference(onChange); + const callOnEscape = useCallbackReference(onEscape); + const callOnLoadError = useCallbackReference(onLoadError); + const callOnLoaderStart = useCallbackReference(onLoaderStart); + const callOnNetworksChange = useCallbackReference(onNetworksChange); + const callOnCheckout = useCallbackReference(onCheckout); + const callOnLineItemClick = useCallbackReference(onLineItemClick); + const callOnConfirm = useCallbackReference(onConfirm); + const callOnCancel = useCallbackReference(onCancel); + const callOnShippingAddressChange = useCallbackReference( onShippingAddressChange ); - useAttachEvent(elementRef, 'shippingratechange', onShippingRateChange); - - let readyCallback: UnknownCallback | undefined; - if (type === 'cart') { - readyCallback = (event) => { - setCartState( - (event as unknown) as stripeJs.StripeCartElementPayloadEvent - ); - onReady && onReady(event); - }; - } else if (onReady) { - if (type === 'payButton') { - // Passes through the event, which includes visible PM types - readyCallback = onReady; - } else { - // For other Elements, pass through the Element itself. - readyCallback = () => { - onReady(elementRef.current); - }; - } - } - - useAttachEvent(elementRef, 'ready', readyCallback); - - const changeCallback = - type === 'cart' - ? (event: stripeJs.StripeCartElementPayloadEvent) => { - setCartState(event); - onChange && onChange(event); - } - : onChange; - - useAttachEvent(elementRef, 'change', changeCallback); - - const checkoutCallback = - type === 'cart' - ? (event: stripeJs.StripeCartElementPayloadEvent) => { - setCartState(event); - onCheckout && onCheckout(event); - } - : onCheckout; - - useAttachEvent(elementRef, 'checkout', checkoutCallback); + const callOnShippingRateChange = useCallbackReference(onShippingRateChange); React.useLayoutEffect(() => { if (elementRef.current == null && elements && domNode.current != null) { @@ -149,6 +107,112 @@ const createElementComponent = ( } elementRef.current = element; element.mount(domNode.current); + element.on('ready', (event) => { + if (type === 'cart') { + // we know that elements.on event must be of type StripeCartPayloadEvent if type is 'cart' + // we need to cast because typescript is not able to infer which overloaded method is used based off param type + if (setCartState) { + setCartState( + (event as unknown) as stripeJs.StripeCartElementPayloadEvent + ); + } + // the cart ready event returns a CartStatePayload instead of the CartElement + callOnReady(event); + } else if (type === 'payButton') { + callOnReady(event); + } else { + callOnReady(element); + } + }); + + element.on('change', (event) => { + if (type === 'cart' && setCartState) { + // we know that elements.on event must be of type StripeCartPayloadEvent if type is 'cart' + // we need to cast because typescript is not able to infer which overloaded method is used based off param type + setCartState( + (event as unknown) as stripeJs.StripeCartElementPayloadEvent + ); + } + callOnChange(event); + }); + + // Users can pass an onBlur prop on any Element component + // just as they could listen for the `blur` event on any Element, + // but only certain Elements will trigger the event. + (element as any).on('blur', callOnBlur); + + // Users can pass an onFocus prop on any Element component + // just as they could listen for the `focus` event on any Element, + // but only certain Elements will trigger the event. + (element as any).on('focus', callOnFocus); + + // Users can pass an onEscape prop on any Element component + // just as they could listen for the `escape` event on any Element, + // but only certain Elements will trigger the event. + (element as any).on('escape', callOnEscape); + + // Users can pass an onLoadError prop on any Element component + // just as they could listen for the `loaderror` event on any Element, + // but only certain Elements will trigger the event. + (element as any).on('loaderror', callOnLoadError); + + // Users can pass an onLoaderStart prop on any Element component + // just as they could listen for the `loaderstart` event on any Element, + // but only certain Elements will trigger the event. + (element as any).on('loaderstart', callOnLoaderStart); + + // Users can pass an onNetworksChange prop on any Element component + // just as they could listen for the `networkschange` event on any Element, + // but only the Card and CardNumber Elements will trigger the event. + (element as any).on('networkschange', callOnNetworksChange); + + // Users can pass an onClick prop on any Element component + // just as they could listen for the `click` event on any Element, + // but only the PaymentRequestButton will actually trigger the event. + (element as any).on('click', callOnClick); + + // Users can pass an onCheckout prop on any Element component + // just as they could listen for the `checkout` event on any Element, + // but only certain Elements will trigger the event. + (element as any).on( + 'checkout', + (event: stripeJs.StripeCartElementPayloadEvent) => { + if (type === 'cart' && setCartState) { + // we know that elements.on event must be of type StripeCartPayloadEvent if type is 'cart' + // we need to cast because typescript is not able to infer which overloaded method is used based off param type + setCartState(event); + } + callOnCheckout(event); + } + ); + + // Users can pass an onLineItemClick prop on any Element component + // just as they could listen for the `lineitemclick` event on any Element, + // but only certain Elements will trigger the event. + (element as any).on('lineitemclick', callOnLineItemClick); + + // Users can pass an onConfirm prop on any Element component + // just as they could listen for the `confirm` event on any Element, + // but only certain Elements will trigger the event. + (element as any).on('confirm', callOnConfirm); + + // Users can pass an onCancel prop on any Element component + // just as they could listen for the `cancel` event on any Element, + // but only certain Elements will trigger the event. + (element as any).on('cancel', callOnCancel); + + // Users can pass an onShippingAddressChange prop on any Element component + // just as they could listen for the `shippingaddresschange` event on any Element, + // but only certain Elements will trigger the event. + (element as any).on( + 'shippingaddresschange', + callOnShippingAddressChange + ); + + // Users can pass an onShippingRateChange prop on any Element component + // just as they could listen for the `shippingratechange` event on any Element, + // but only certain Elements will trigger the event. + (element as any).on('shippingratechange', callOnShippingRateChange); } }); diff --git a/src/utils/useAttachEvent.ts b/src/utils/useAttachEvent.ts deleted file mode 100644 index acbd8b4..0000000 --- a/src/utils/useAttachEvent.ts +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import * as stripeJs from '@stripe/stripe-js'; - -export const useAttachEvent = ( - elementRef: React.MutableRefObject, - event: string, - cb?: (...args: A) => any -) => { - React.useEffect(() => { - if (!cb || !elementRef.current) { - return () => {}; - } - - const element = elementRef.current; - - (element as any).on(event, cb); - - return () => (element as any).off(event, cb); - }, [cb, event, elementRef]); -}; diff --git a/src/utils/useCallbackReference.ts b/src/utils/useCallbackReference.ts new file mode 100644 index 0000000..625b2c5 --- /dev/null +++ b/src/utils/useCallbackReference.ts @@ -0,0 +1,17 @@ +import React from 'react'; + +export const useCallbackReference = ( + cb?: (...args: A) => any +): ((...args: A) => void) => { + const ref = React.useRef(cb); + + React.useEffect(() => { + ref.current = cb; + }, [cb]); + + return (...args): void => { + if (ref.current) { + ref.current(...args); + } + }; +};