Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/react-native-contentpass-cmp-onetrust/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native-contentpass-cmp-onetrust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const CONSENT_CHANGE_EVENTS = new Set<OTEventName>([
]);

const CONSENT_STATUS_REFRESH_DELAYS_MS = [100, 500, 1000];
const BANNER_SETTLEMENT_TIMEOUT_MS = 10_000;

export type OnetrustCmpAdapterOptions = {
/**
Expand All @@ -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 = {}
Expand Down Expand Up @@ -83,6 +90,7 @@ export default class OnetrustCmpAdapter implements CmpAdapter {
(fullConsent: boolean) => void
>();
private consentStatusRevision = 0;
private bannerAcknowledgedUntil = 0;

constructor(
private readonly sdk: OTPublishersNativeSDK,
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down Expand Up @@ -219,14 +229,14 @@ export default class OnetrustCmpAdapter implements CmpAdapter {

hasFullConsent = async (): Promise<boolean> => {
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 {
Expand Down Expand Up @@ -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<void> {
Expand All @@ -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,
});
Expand Down
Loading