diff --git a/.changeset/breezy-oranges-speak.md b/.changeset/breezy-oranges-speak.md new file mode 100644 index 000000000..17bf98ad3 --- /dev/null +++ b/.changeset/breezy-oranges-speak.md @@ -0,0 +1,5 @@ +--- +'@guardian/consent-management-platform': minor +--- + +Adding extra final parameter onConsentChange diff --git a/README.md b/README.md index 8e23cbf5a..58ff32282 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ and TCFv2 to everyone else. * [`cmp.willShowPrivacyMessageSync()`](#cmpwillshowprivacymessagesync) * [`cmp.showPrivacyManager()`](#cmpshowprivacymanager) - [Using Consent](#using-consent) - * [`onConsentChange(callback)`](#onconsentchangecallback) + * [`onConsentChange(callback, final?)`](#onconsentchangecallback-final) * [`onConsent()`](#onconsent) * [`getConsentFor(vendor, consentState)`](#getconsentforvendor-consentstate) - [Disabling Consent](#disabling-consent) @@ -172,7 +172,7 @@ import { } from '@guardian/consent-management-platform'; ``` -### `onConsentChange(callback)` +### `onConsentChange(callback, final?)` returns: `void` @@ -184,6 +184,11 @@ An event listener that invokes callbacks whenever the consent state: If the consent state has already been acquired when `onConsentChange` is called, the callback will be invoked immediately. +Passing `true` for the optional `final` parameter guarantees that the callback +will be executed after all other callbacks that haven't been registered with the flag when consent state changes. +If more than one callback registered with `final = true`, they will be executed in the order in which they were registered +when consent changes. + #### `callback(consentState)` type: `function` diff --git a/src/onConsentChange.test.js b/src/onConsentChange.test.js index c8fda7a99..bc0242426 100644 --- a/src/onConsentChange.test.js +++ b/src/onConsentChange.test.js @@ -72,6 +72,66 @@ describe('under CCPA', () => { expect(callback).toHaveBeenCalledTimes(2); }); }); + + + it('callbacks executed in correct order', async () => { + let callbackLastExecuted = {}; + const setCallbackLastExecuted = (callback) => { + const now = window.performance.now(); + callbackLastExecuted[callback] = now; + }; + const callback1 = jest.fn(() => setCallbackLastExecuted(1)); + const callback2 = jest.fn(() => setCallbackLastExecuted(2)); + const callback3 = jest.fn(() => setCallbackLastExecuted(3)); + const callback4 = jest.fn(() => setCallbackLastExecuted(4)); + + uspData.uspString = '1YYN'; + + // callback 3 and 4 registered first with final flag + onConsentChange(callback3, true); + onConsentChange(callback4, true); + onConsentChange(callback1); + onConsentChange(callback2); + + await waitForExpect(() => { + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).toHaveBeenCalledTimes(1); + expect(callback4).toHaveBeenCalledTimes(1); + + // callbacks initially executed in order they were registered in + expect(callbackLastExecuted[3]).toBeLessThan( + callbackLastExecuted[4], + ); + expect(callbackLastExecuted[4]).toBeLessThan( + callbackLastExecuted[1], + ); + expect(callbackLastExecuted[1]).toBeLessThan( + callbackLastExecuted[2], + ); + }); + + uspData.uspString = '1YNN'; + invokeCallbacks(); + + await waitForExpect(() => { + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledTimes(2); + expect(callback3).toHaveBeenCalledTimes(2); + expect(callback4).toHaveBeenCalledTimes(2); + + // after consent state change, callbacks were executed in order 1, 2, 3, 4 + expect(callbackLastExecuted[1]).toBeLessThan( + callbackLastExecuted[2], + ); + expect(callbackLastExecuted[2]).toBeLessThan( + callbackLastExecuted[3], + ); + expect(callbackLastExecuted[3]).toBeLessThan( + callbackLastExecuted[4], + ); + }); + }); }); describe('under AUS', () => { @@ -129,6 +189,66 @@ describe('under AUS', () => { expect(callback).toHaveBeenCalledTimes(2); }); }); + + + it('callbacks executed in correct order', async () => { + let callbackLastExecuted = {}; + const setCallbackLastExecuted = (callback) => { + const now = window.performance.now(); + callbackLastExecuted[callback] = now; + }; + const callback1 = jest.fn(() => setCallbackLastExecuted(1)); + const callback2 = jest.fn(() => setCallbackLastExecuted(2)); + const callback3 = jest.fn(() => setCallbackLastExecuted(3)); + const callback4 = jest.fn(() => setCallbackLastExecuted(4)); + + ausData.uspString = '1YYN'; + + // callback 3 and 4 registered first with final flag + onConsentChange(callback3, true); + onConsentChange(callback4, true); + onConsentChange(callback1); + onConsentChange(callback2); + + await waitForExpect(() => { + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).toHaveBeenCalledTimes(1); + expect(callback4).toHaveBeenCalledTimes(1); + + // callbacks initially executed in order they were registered in + expect(callbackLastExecuted[3]).toBeLessThan( + callbackLastExecuted[4], + ); + expect(callbackLastExecuted[4]).toBeLessThan( + callbackLastExecuted[1], + ); + expect(callbackLastExecuted[1]).toBeLessThan( + callbackLastExecuted[2], + ); + }); + + ausData.uspString = '1YNN'; + invokeCallbacks(); + + await waitForExpect(() => { + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledTimes(2); + expect(callback3).toHaveBeenCalledTimes(2); + expect(callback4).toHaveBeenCalledTimes(2); + + // after consent state change, callbacks were executed in order 1, 2, 3, 4 + expect(callbackLastExecuted[1]).toBeLessThan( + callbackLastExecuted[2], + ); + expect(callbackLastExecuted[2]).toBeLessThan( + callbackLastExecuted[3], + ); + expect(callbackLastExecuted[3]).toBeLessThan( + callbackLastExecuted[4], + ); + }); + }); }); describe('under TCFv2', () => { @@ -216,4 +336,53 @@ describe('under TCFv2', () => { expect(callback).toHaveBeenCalledTimes(2); }); }); + + it('callbacks executed in correct order', async () => { + let callbackLastExecuted = {}; + const setCallbackLastExecuted = (callback) => { + const now = window.performance.now(); + callbackLastExecuted[callback] = now; + }; + const callback1 = jest.fn(() => setCallbackLastExecuted(1)); + const callback2 = jest.fn(() => setCallbackLastExecuted(2)); + const callback3 = jest.fn(() => setCallbackLastExecuted(3)); + const callback4 = jest.fn(() => setCallbackLastExecuted(4)); + + tcData.eventStatus = 'cmpuishown'; + + // callback 3 and 4 registered first with final flag + onConsentChange(callback3, true); + onConsentChange(callback4, true); + onConsentChange(callback1); + onConsentChange(callback2); + + await waitForExpect(() => { + expect(callback1).toHaveBeenCalledTimes(0); + expect(callback2).toHaveBeenCalledTimes(0); + expect(callback3).toHaveBeenCalledTimes(0); + expect(callback4).toHaveBeenCalledTimes(0); + }); + + tcData.eventStatus = 'useractioncomplete'; + + invokeCallbacks(); + + await waitForExpect(() => { + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).toHaveBeenCalledTimes(1); + expect(callback4).toHaveBeenCalledTimes(1); + + // callbacks were executed in order 1, 2, 3, 4 + expect(callbackLastExecuted[1]).toBeLessThan( + callbackLastExecuted[2], + ); + expect(callbackLastExecuted[2]).toBeLessThan( + callbackLastExecuted[3], + ); + expect(callbackLastExecuted[3]).toBeLessThan( + callbackLastExecuted[4], + ); + }); + }); }); diff --git a/src/onConsentChange.ts b/src/onConsentChange.ts index d8a662d59..ba0fdcd8a 100644 --- a/src/onConsentChange.ts +++ b/src/onConsentChange.ts @@ -16,6 +16,7 @@ interface ConsentStateBasic { // callbacks cache const callBackQueue: CallbackQueueItem[] = []; +const finalCallbackQueue: CallbackQueueItem[] = []; /** * In TCFv2, check whether the event status anything but `cmpuishown`, i.e.: @@ -98,18 +99,23 @@ const getConsentState: () => Promise = async () => { // invokes all stored callbacks with the current consent state export const invokeCallbacks = (): void => { - if (callBackQueue.length === 0) return; + const callbacksToInvoke = callBackQueue.concat(finalCallbackQueue) + if (callbacksToInvoke.length === 0) return; void getConsentState().then((state) => { if (awaitingUserInteractionInTCFv2(state)) return; - callBackQueue.forEach((callback) => invokeCallback(callback, state)); + callbacksToInvoke.forEach((callback) => invokeCallback(callback, state)); }); }; -export const onConsentChange: OnConsentChange = (callBack) => { +export const onConsentChange: OnConsentChange = (callBack, final = false) => { const newCallback: CallbackQueueItem = { fn: callBack }; - callBackQueue.push(newCallback); + if (final) { + finalCallbackQueue.push(newCallback); + } else { + callBackQueue.push(newCallback) + } // if consentState is already available, invoke callback immediately void getConsentState() diff --git a/src/types/index.ts b/src/types/index.ts index 38fa7e8d2..cca286095 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -20,7 +20,7 @@ export type CMP = { export type InitCMP = (arg0: { pubData?: PubData; country?: Country }) => void; -export type OnConsentChange = (fn: Callback) => void; +export type OnConsentChange = (fn: Callback, final?: boolean) => void; export type GetConsentFor = ( vendor: VendorName, consent: ConsentState,