diff --git a/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md b/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md index 94817f0..2cc30e6 100644 --- a/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md +++ b/packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md @@ -1,5 +1,11 @@ # @contentpass/react-native-contentpass-cmp-onetrust +## 0.4.1 + +### Patch Changes + +- Handle the short delay before OneTrust clears its banner state after accepting all consent, preventing the Contentpass layer from remaining open on cold start. + ## 0.4.0 ### Minor Changes diff --git a/packages/react-native-contentpass-cmp-onetrust/README.md b/packages/react-native-contentpass-cmp-onetrust/README.md index 166a2f1..8ce2259 100644 --- a/packages/react-native-contentpass-cmp-onetrust/README.md +++ b/packages/react-native-contentpass-cmp-onetrust/README.md @@ -156,7 +156,7 @@ Do not run a second OneTrust `saveConsent` call for ATT from `acceptAll()`. The ### Diagnostic logs -The adapter emits structured `console.debug` logs for CMP initialization, OneTrust events, consent actions, group statuses, ATT status (when the installed bridge exposes it), and stale asynchronous reads. After `acceptAll()` or `denyAll()`, it records immediate snapshots and rechecks consent after 100, 500, and 1000 milliseconds. This makes native persistence delays, re-consent, and ATT-linked group states visible without changing the user's consent. +The adapter emits structured `console.debug` logs for CMP initialization, OneTrust events, consent actions, group statuses, ATT status (when the installed bridge exposes it), and stale asynchronous reads. After `acceptAll()` or `denyAll()`, it records immediate snapshots and rechecks consent after 100, 500, and 1000 milliseconds. After a successful `acceptAll()`, the adapter allows OneTrust up to 10 seconds to settle `shouldShowBanner`; the temporary acknowledgement clears as soon as OneTrust returns `false`. This makes native persistence delays, re-consent, and ATT-linked group states visible without changing the user's consent. ### `CmpAdapter` methods provided diff --git a/packages/react-native-contentpass-cmp-onetrust/package.json b/packages/react-native-contentpass-cmp-onetrust/package.json index ffd7959..f31f0fb 100644 --- a/packages/react-native-contentpass-cmp-onetrust/package.json +++ b/packages/react-native-contentpass-cmp-onetrust/package.json @@ -1,6 +1,6 @@ { "name": "@contentpass/react-native-contentpass-cmp-onetrust", - "version": "0.4.0", + "version": "0.4.1", "description": "Contentpass OneTrust CMP adapter", "source": "./src/index.ts", "main": "./lib/commonjs/index.js", diff --git a/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.test.ts b/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.test.ts index 1bf3b03..528d204 100644 --- a/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.test.ts +++ b/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.test.ts @@ -141,6 +141,65 @@ describe('OnetrustCmpAdapter', () => { await expect(adapter.hasFullConsent()).resolves.toBe(false); }); + it('should accept consent during the banner settlement period', async () => { + jest.useFakeTimers(); + try { + const { sdk } = createMockSdk({ + shouldShowBanner: jest.fn().mockResolvedValue(true), + }); + const adapter = await createOnetrustCmpAdapter(sdk); + + await expect(adapter.hasFullConsent()).resolves.toBe(false); + + await adapter.acceptAll(); + + await expect(adapter.hasFullConsent()).resolves.toBe(true); + } finally { + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + it('should require consent when the banner remains visible after the settlement period', async () => { + jest.useFakeTimers(); + try { + const now = new Date('2026-07-20T12:00:00.000Z'); + jest.setSystemTime(now); + const { sdk } = createMockSdk({ + shouldShowBanner: jest.fn().mockResolvedValue(true), + }); + const adapter = await createOnetrustCmpAdapter(sdk); + + await adapter.acceptAll(); + await expect(adapter.hasFullConsent()).resolves.toBe(true); + + jest.setSystemTime(new Date(now.getTime() + 10_001)); + await expect(adapter.hasFullConsent()).resolves.toBe(false); + } finally { + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + it('should require consent again after rejecting the Contentpass banner', async () => { + jest.useFakeTimers(); + try { + const { sdk } = createMockSdk({ + shouldShowBanner: jest.fn().mockResolvedValue(true), + }); + const adapter = await createOnetrustCmpAdapter(sdk); + + await adapter.acceptAll(); + await expect(adapter.hasFullConsent()).resolves.toBe(true); + + await adapter.denyAll(); + await expect(adapter.hasFullConsent()).resolves.toBe(false); + } finally { + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + it('should ignore an initial status read that resolves after accept all', async () => { jest.useFakeTimers(); try { diff --git a/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts b/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts index 9a6d118..5a4c2fb 100644 --- a/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts +++ b/packages/react-native-contentpass-cmp-onetrust/src/OnetrustCmpAdapter.ts @@ -12,6 +12,7 @@ const CONSENT_CHANGE_EVENTS = new Set([ ]); const CONSENT_STATUS_REFRESH_DELAYS_MS = [100, 500, 1000]; +const BANNER_SETTLEMENT_TIMEOUT_MS = 10_000; export type OnetrustCmpAdapterOptions = { /** @@ -35,6 +36,12 @@ type ConsentState = { consentStatuses: ConsentStatus[]; }; +type FullConsentDecision = { + fullConsent: boolean; + bannerSettlementActive: boolean; + bannerAcknowledgedUntil: number | null; +}; + export async function createOnetrustCmpAdapter( sdk: OTPublishersNativeSDK, options: OnetrustCmpAdapterOptions = {} @@ -83,6 +90,7 @@ export default class OnetrustCmpAdapter implements CmpAdapter { (fullConsent: boolean) => void >(); private consentStatusRevision = 0; + private bannerAcknowledgedUntil = 0; constructor( private readonly sdk: OTPublishersNativeSDK, @@ -139,6 +147,7 @@ export default class OnetrustCmpAdapter implements CmpAdapter { console.debug('[OnetrustCmpAdapter::acceptAll] saveConsent resolved', { revision, }); + this.bannerAcknowledgedUntil = Date.now() + BANNER_SETTLEMENT_TIMEOUT_MS; this.logConsentSnapshot('acceptAll: after saveConsent'); this.scheduleConsentStatusRefreshes(revision, 'acceptAll'); await this.emitConsentStatusForRevision(revision, 'acceptAll'); @@ -163,6 +172,7 @@ export default class OnetrustCmpAdapter implements CmpAdapter { console.debug('[OnetrustCmpAdapter::denyAll] saveConsent resolved', { revision, }); + this.bannerAcknowledgedUntil = 0; this.logConsentSnapshot('denyAll: after saveConsent'); this.scheduleConsentStatusRefreshes(revision, 'denyAll'); await this.emitConsentStatusForRevision(revision, 'denyAll'); @@ -219,14 +229,14 @@ export default class OnetrustCmpAdapter implements CmpAdapter { hasFullConsent = async (): Promise => { const consentState = await this.getConsentState(); - const fullConsent = this.hasFullConsentForState(consentState); + const consentDecision = this.getFullConsentDecision(consentState); console.debug('[OnetrustCmpAdapter::hasFullConsent]', { - fullConsent, + ...consentDecision, ...consentState, }); - return fullConsent; + return consentDecision.fullConsent; }; onConsentStatusChange(callback: (fullConsent: boolean) => void): () => void { @@ -402,16 +412,30 @@ export default class OnetrustCmpAdapter implements CmpAdapter { return { shouldShowBanner, consentStatuses: statuses }; } - private hasFullConsentForState({ + private getFullConsentDecision({ shouldShowBanner, consentStatuses, - }: ConsentState): boolean { - return ( - !shouldShowBanner && + }: ConsentState): FullConsentDecision { + const now = Date.now(); + if ( + !shouldShowBanner || + (this.bannerAcknowledgedUntil > 0 && now >= this.bannerAcknowledgedUntil) + ) { + this.bannerAcknowledgedUntil = 0; + } + + const bannerSettlementActive = this.bannerAcknowledgedUntil > 0; + const fullConsent = + (!shouldShowBanner || bannerSettlementActive) && consentStatuses .filter(({ isAttGroup }) => !isAttGroup) - .every(({ status }) => status === 1) - ); + .every(({ status }) => status === 1); + + return { + fullConsent, + bannerSettlementActive, + bannerAcknowledgedUntil: this.bannerAcknowledgedUntil || null, + }; } private async logConsentSnapshot(context: string): Promise { @@ -420,10 +444,11 @@ export default class OnetrustCmpAdapter implements CmpAdapter { this.getConsentState(), this.getAttStatus(), ]); + const consentDecision = this.getFullConsentDecision(consentState); console.debug('[OnetrustCmpAdapter::consentSnapshot]', { context, timestamp: new Date().toISOString(), - fullConsent: this.hasFullConsentForState(consentState), + ...consentDecision, attStatus, ...consentState, });