diff --git a/app/javascript/packages/document-capture/context/acuant.jsx b/app/javascript/packages/document-capture/context/acuant.jsx index 02e2d50d6d8..cda06a77719 100644 --- a/app/javascript/packages/document-capture/context/acuant.jsx +++ b/app/javascript/packages/document-capture/context/acuant.jsx @@ -1,6 +1,7 @@ import { createContext, useContext, useMemo, useEffect, useState } from 'react'; import DeviceContext from './device'; import AnalyticsContext from './analytics'; +import useObjectMemo from '@18f/identity-react-hooks/use-object-memo'; /** @typedef {import('react').ReactNode} ReactNode */ @@ -147,32 +148,18 @@ function AcuantContextProvider({ // types should treat camera as unsupported, since it's not relevant for Acuant SDK usage. const [isCameraSupported, setIsCameraSupported] = useState(isMobile ? null : false); const [isActive, setIsActive] = useState(false); - const value = useMemo( - () => ({ - isReady, - isAcuantLoaded, - isError, - isCameraSupported, - isActive, - setIsActive, - endpoint, - credentials, - glareThreshold, - sharpnessThreshold, - }), - [ - isReady, - isAcuantLoaded, - isError, - isCameraSupported, - isActive, - setIsActive, - endpoint, - credentials, - glareThreshold, - sharpnessThreshold, - ], - ); + const value = useObjectMemo({ + isReady, + isAcuantLoaded, + isError, + isCameraSupported, + isActive, + setIsActive, + endpoint, + credentials, + glareThreshold, + sharpnessThreshold, + }); useEffect(() => { // If state is already ready (via consideration of device type), skip loading Acuant SDK. diff --git a/app/javascript/packages/document-capture/context/upload.jsx b/app/javascript/packages/document-capture/context/upload.jsx index 4aedd4fbfb8..dafc7c80fd2 100644 --- a/app/javascript/packages/document-capture/context/upload.jsx +++ b/app/javascript/packages/document-capture/context/upload.jsx @@ -1,4 +1,5 @@ -import { createContext, useMemo } from 'react'; +import { createContext } from 'react'; +import { useObjectMemo } from '@18f/identity-react-hooks'; import defaultUpload from '../services/upload'; const UploadContext = createContext({ @@ -102,28 +103,16 @@ function UploadContextProvider({ ? upload({ ...formData }, { endpoint: statusEndpoint, method, csrf }) : Promise.reject(); - const value = useMemo( - () => ({ - upload: uploadWithCSRF, - getStatus, - statusPollInterval, - backgroundUploadURLs, - backgroundUploadEncryptKey, - isMockClient, - flowPath, - csrf, - }), - [ - upload, - getStatus, - statusPollInterval, - backgroundUploadURLs, - backgroundUploadEncryptKey, - isMockClient, - flowPath, - csrf, - ], - ); + const value = useObjectMemo({ + upload: uploadWithCSRF, + getStatus, + statusPollInterval, + backgroundUploadURLs, + backgroundUploadEncryptKey, + isMockClient, + flowPath, + csrf, + }); return {children}; } diff --git a/app/javascript/packages/react-hooks/index.ts b/app/javascript/packages/react-hooks/index.ts index 10c1e14a8de..e5d359b4176 100644 --- a/app/javascript/packages/react-hooks/index.ts +++ b/app/javascript/packages/react-hooks/index.ts @@ -2,3 +2,4 @@ export { default as useDidUpdateEffect } from './use-did-update-effect'; export { default as useIfStillMounted } from './use-if-still-mounted'; export { default as useImmutableCallback } from './use-immutable-callback'; export { default as useInstanceId } from './use-instance-id'; +export { default as useObjectMemo } from './use-object-memo'; diff --git a/app/javascript/packages/react-hooks/use-object-memo.spec.ts b/app/javascript/packages/react-hooks/use-object-memo.spec.ts new file mode 100644 index 00000000000..b1fc2fec2df --- /dev/null +++ b/app/javascript/packages/react-hooks/use-object-memo.spec.ts @@ -0,0 +1,35 @@ +import { renderHook } from '@testing-library/react-hooks'; +import useObjectMemo from './use-object-memo'; + +describe('useObjectMemo', () => { + it('maintains reference over re-render for identical object values', () => { + const { rerender, result } = renderHook(({ object }) => useObjectMemo(object), { + initialProps: { object: { a: 1 } }, + }); + + const { current: object1 } = result; + + rerender({ object: { a: 1 } }); + + const { current: object2 } = result; + + expect(object1).to.equal(object2); + expect(object1).to.deep.equal({ a: 1 }); + }); + + it('updates reference when re-rendering with new values', () => { + const { rerender, result } = renderHook(({ object }) => useObjectMemo(object), { + initialProps: { object: { a: 1 } }, + }); + + const { current: object1 } = result; + expect(object1).to.deep.equal({ a: 1 }); + + rerender({ object: { a: 2 } }); + + const { current: object2 } = result; + expect(object2).to.deep.equal({ a: 2 }); + + expect(object1).to.not.equal(object2); + }); +}); diff --git a/app/javascript/packages/react-hooks/use-object-memo.ts b/app/javascript/packages/react-hooks/use-object-memo.ts new file mode 100644 index 00000000000..ddfbe7290e2 --- /dev/null +++ b/app/javascript/packages/react-hooks/use-object-memo.ts @@ -0,0 +1,20 @@ +import { useMemo } from 'react'; + +/** + * React hook which creates a memoized object whose reference changes when values of the object + * changes. + * + * This can be useful in situations like object context values, since without memoization an object + * context would trigger re-renders of all consumers on every update. + * + * Note that the keys of the object must remain the same for the lifecycle of the component for the + * hook to work correctly. + * + * @param object Object to memoize. + * + * @return Memoized object. + */ +const useObjectMemo = (object: T): T => + useMemo(() => object, Object.values(object)); + +export default useObjectMemo; diff --git a/app/javascript/packages/verify-flow/verify-flow.tsx b/app/javascript/packages/verify-flow/verify-flow.tsx index 50e41235f7d..3ffdc202060 100644 --- a/app/javascript/packages/verify-flow/verify-flow.tsx +++ b/app/javascript/packages/verify-flow/verify-flow.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useState } from 'react'; import { FormSteps } from '@18f/identity-form-steps'; import { trackEvent } from '@18f/identity-analytics'; import { getConfigValue } from '@18f/identity-config'; +import { useObjectMemo } from '@18f/identity-react-hooks'; import { STEPS } from './steps'; import VerifyFlowStepIndicator from './verify-flow-step-indicator'; import VerifyFlowAlert from './verify-flow-alert'; @@ -112,10 +113,13 @@ function VerifyFlow({ const [syncedValues, setSyncedValues] = useSyncedSecretValues(initialValues); const [currentStep, setCurrentStep] = useState(steps[0].name); const [initialStep, setCompletedStep] = useInitialStepValidation(basePath, steps); - const context = useMemo( - () => ({ startOverURL, cancelURL, currentStep, basePath, resetPasswordUrl }), - [startOverURL, cancelURL, currentStep, basePath, resetPasswordUrl], - ); + const context = useObjectMemo({ + startOverURL, + cancelURL, + currentStep, + basePath, + resetPasswordUrl, + }); useEffect(() => { logStepVisited(currentStep); }, [currentStep]);