diff --git a/app/javascript/packages/document-capture/hooks/use-cookie.js b/app/javascript/packages/document-capture/hooks/use-cookie.js index d84197ec0c6..9998f058bb4 100644 --- a/app/javascript/packages/document-capture/hooks/use-cookie.js +++ b/app/javascript/packages/document-capture/hooks/use-cookie.js @@ -1,4 +1,13 @@ -import { useState, useEffect } from 'react'; +import { createContext, useContext, useState, useEffect } from 'react'; + +/** @typedef {import('react').Dispatch} Dispatch @template A */ +/** @typedef {import('react').SetStateAction} SetStateAction @template S */ + +/** + * @typedef {Dispatch>[]} Subscribers + */ + +const CookieSubscriberContext = createContext(/** @type {Map} */ (new Map())); /** * React hook to access and manage a cookie value by name. @@ -8,13 +17,30 @@ import { useState, useEffect } from 'react'; * @return {[value: string|null, setValue: (nextValue: string?) => void]} */ function useCookie(name) { - const getCookieValue = () => + const getValue = () => document.cookie .split(';') .map((part) => part.trim().split('=')) .find(([key]) => key === name)?.[1] ?? null; - const [value, setStateValue] = useState(getCookieValue); + const subscriptions = useContext(CookieSubscriberContext); + const [value, setStateValue] = useState(getValue); + + useEffect(() => { + if (!subscriptions.has(name)) { + subscriptions.set(name, []); + } + + const subscribers = /** @type {Subscribers} */ (subscriptions.get(name)); + subscribers.push(setStateValue); + + return () => { + subscribers.splice(subscribers.indexOf(setStateValue), 1); + if (!subscribers.length) { + subscriptions.delete(name); + } + }; + }, [name]); /** * @param {string?} nextValue Value to set, or null to delete the value. @@ -22,28 +48,10 @@ function useCookie(name) { function setValue(nextValue) { const cookieValue = nextValue === null ? '; Max-Age=0' : nextValue; document.cookie = `${name}=${cookieValue}`; - setStateValue(nextValue); + const subscribers = /** @type {Subscribers} */ (subscriptions.get(name)); + subscribers.forEach((setSubscriberValue) => setSubscriberValue(getValue)); } - useEffect(() => { - const originalCookieDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie'); - Object.defineProperty(Document.prototype, 'cookie', { - ...originalCookieDescriptor, - set(nextValue) { - originalCookieDescriptor?.set?.call(this, nextValue); - setStateValue(getCookieValue); - }, - }); - - return () => { - Object.defineProperty( - Document.prototype, - 'cookie', - /** @type {PropertyDescriptor} */ (originalCookieDescriptor), - ); - }; - }, []); - return [value, setValue]; } diff --git a/spec/javascripts/packages/document-capture/hooks/use-cookie-spec.jsx b/spec/javascripts/packages/document-capture/hooks/use-cookie-spec.jsx index 2108cb14b06..85fb590a9a8 100644 --- a/spec/javascripts/packages/document-capture/hooks/use-cookie-spec.jsx +++ b/spec/javascripts/packages/document-capture/hooks/use-cookie-spec.jsx @@ -1,3 +1,4 @@ +import sinon from 'sinon'; import { renderHook } from '@testing-library/react-hooks'; import useCookie from '@18f/identity-document-capture/hooks/use-cookie'; @@ -14,20 +15,15 @@ describe('document-capture/hooks/use-cookie', () => { expect(value).to.equal('baz'); }); - it('does not interfere with default cookie setting behavior', () => { - renderHook(() => useCookie('foo')); - - document.cookie = 'foo=bar'; - - expect(document.cookie).to.equal('foo=bar'); - }); - it('sets a new cookie value', () => { - const { result } = renderHook(() => useCookie('foo')); + const render = sinon.stub().callsFake(() => useCookie('foo')); + const { result } = renderHook(render); const [, setValue] = result.current; + render.resetHistory(); setValue('bar'); + expect(render).to.have.been.calledOnce(); const [value] = result.current; @@ -38,15 +34,39 @@ describe('document-capture/hooks/use-cookie', () => { it('unsets a cookie value by null', () => { document.cookie = 'foo=bar'; - const { result } = renderHook(() => useCookie('foo')); + const render = sinon.stub().callsFake(() => useCookie('foo')); + const { result } = renderHook(render); const [, setValue] = result.current; + render.resetHistory(); setValue(null); + expect(render).to.have.been.calledOnce(); const [value] = result.current; expect(document.cookie).to.equal(''); expect(value).to.be.null(); }); + + it('returns the same updated value between instances', () => { + const render1 = sinon.stub().callsFake(() => useCookie('foo')); + const render2 = sinon.stub().callsFake(() => useCookie('foo')); + const { result: result1 } = renderHook(render1); + const { result: result2 } = renderHook(render2); + + const [, setValue] = result1.current; + + render1.resetHistory(); + render2.resetHistory(); + setValue('bar'); + expect(render1).to.have.been.calledOnce(); + expect(render2).to.have.been.calledOnce(); + + const [value1] = result1.current; + const [value2] = result2.current; + + expect(value1).to.equal('bar'); + expect(value2).to.equal('bar'); + }); }); diff --git a/spec/javascripts/support/console.js b/spec/javascripts/support/console.js index 2bb6cd18817..539e7107b94 100644 --- a/spec/javascripts/support/console.js +++ b/spec/javascripts/support/console.js @@ -44,15 +44,17 @@ export function chaiConsoleSpy(chai, utils) { * `chaiConsoleSpy` Chai plugin. */ export function useConsoleLogSpy() { + let originalConsoleError; beforeEach(() => { console.unverifiedCalls = []; - sinon.stub(console, 'error').callsFake((message, ...args) => { + originalConsoleError = console.error; + console.error = sinon.stub().callsFake((message, ...args) => { console.unverifiedCalls = console.unverifiedCalls.concat(format(message, ...args)); }); }); afterEach(() => { - console.error.restore(); + console.error = originalConsoleError; expect(console.unverifiedCalls).to.be.empty( `Unexpected console logging: ${console.unverifiedCalls.join(', ')}`, );