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
7 changes: 7 additions & 0 deletions examples/consentmanager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# @contentpass/examples-consentmanager

## 0.0.7

### Patch Changes

- Updated dependencies []:
- @contentpass/react-native-contentpass-ui@0.7.1

## 0.0.6

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion examples/consentmanager/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contentpass/examples-consentmanager",
"version": "0.0.6",
"version": "0.0.7",
"main": "index.ts",
"scripts": {
"start": "expo start",
Expand Down
6 changes: 6 additions & 0 deletions packages/react-native-contentpass-ui/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @contentpass/react-native-contentpass-ui

## 0.7.1

### Patch Changes

- Fix iOS ready race

## 0.7.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native-contentpass-ui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@contentpass/react-native-contentpass-ui",
"version": "0.7.0",
"version": "0.7.1",
"description": "Contentpass React Native UI Components",
"source": "./src/index.tsx",
"main": "./lib/commonjs/index.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import type {
} from '@contentpass/react-native-contentpass';
import ContentpassLayer from './ContentpassLayer';
import type { ContentpassLayerEvents } from './ContentpassLayerEvents';
import {
loadCmpMetadata,
observeCmpConsentStatus,
type CmpMetadata,
} from './ContentpassConsentGateStartup';

type ContentpassConsentGateProps = {
children: React.ReactNode;
Expand All @@ -31,15 +36,23 @@ export default function ContentpassConsentGate({
}: ContentpassConsentGateProps) {
const sdk = useContentpassSdk();
const [cmpReady, setCmpReady] = useState(false);
const [hasFullConsent, setHasFullConsent] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [cpAuthState, setCpAuthState] = useState<ContentpassState | null>(null);
const [isShowingSecondLayer, setIsShowingSecondLayer] = useState(false);
const [isShowingContentpass, setIsShowingContentpass] = useState(false);

const [consentResolved, setConsentResolved] = useState(false);
const [purposesList, setPurposesList] = useState<string[]>([]);
const [vendorCount, setVendorCount] = useState(0);
const [cmpMetadata, setCmpMetadata] = useState<
(CmpMetadata & { adapter: CmpAdapter }) | null
>(null);
const currentCmpMetadata =
cmpMetadata?.adapter === cmpAdapter ? cmpMetadata : null;
const [cmpConsentStatus, setCmpConsentStatus] = useState<{
adapter: CmpAdapter;
hasFullConsent: boolean;
} | null>(null);
const currentCmpConsentStatus =
cmpConsentStatus?.adapter === cmpAdapter ? cmpConsentStatus : null;

const layerEvents = useMemo(() => {
return {
Expand Down Expand Up @@ -105,30 +118,33 @@ export default function ContentpassConsentGate({
}

let active = true;
const unsubscribe = cmpAdapter.onConsentStatusChange((v: boolean) => {
console.debug('[ContentpassConsentGate::onConsentStatusChange]', {
fullConsent: v,
active,
});
if (active) {
setHasFullConsent(v);
}
});
cmpAdapter.getRequiredPurposes().then((v: string[]) => {
if (active) {
setPurposesList(v);
}
});
cmpAdapter.getNumberOfVendors().then((v: number) => {
if (active) {
setVendorCount(v);
const stopObservingConsent = observeCmpConsentStatus(
cmpAdapter,
(hasFullConsent) => {
console.debug('[ContentpassConsentGate::onConsentStatusChange]', {
fullConsent: hasFullConsent,
});
setCmpConsentStatus({ adapter: cmpAdapter, hasFullConsent });
},
(error) => {
console.error('Failed to load initial CMP consent status', error);
setCmpConsentStatus({ adapter: cmpAdapter, hasFullConsent: false });
}
});
);
loadCmpMetadata(cmpAdapter)
.then((metadata) => {
if (active) {
setCmpMetadata({ ...metadata, adapter: cmpAdapter });
}
})
.catch((error) => {
console.error('Failed to load CMP metadata', error);
});

return () => {
active = false;
console.debug('[ContentpassConsentGate::onConsentStatusChange] cleanup');
unsubscribe?.();
stopObservingConsent();
};
}, [cmpReady, cmpAdapter]);

Expand All @@ -147,6 +163,8 @@ export default function ContentpassConsentGate({
];
if (
!cmpReady ||
!currentCmpMetadata ||
!currentCmpConsentStatus ||
!cpAuthState ||
invalidStates.includes(cpAuthState.state)
) {
Expand All @@ -162,12 +180,12 @@ export default function ContentpassConsentGate({

const isFine =
cpAuthState.state === ContentpassStateType.AUTHENTICATED ||
hasFullConsent;
currentCmpConsentStatus.hasFullConsent;
const visible = !isFine;
console.debug('[ContentpassConsentGate::visibility]', {
cmpReady,
contentpassState: cpAuthState.state,
hasFullConsent,
hasFullConsent: currentCmpConsentStatus.hasFullConsent,
isShowingContentpass,
isShowingSecondLayer,
visible,
Expand All @@ -179,8 +197,9 @@ export default function ContentpassConsentGate({
setConsentResolved(true);
}, [
cmpReady,
currentCmpMetadata,
currentCmpConsentStatus,
cpAuthState,
hasFullConsent,
isShowingContentpass,
isShowingSecondLayer,
isVisible,
Expand All @@ -207,8 +226,8 @@ export default function ContentpassConsentGate({
instanceId={sdk.instanceId}
planId={contentpassConfig.planId}
propertyId={contentpassConfig.propertyId}
purposesList={purposesList}
vendorCount={vendorCount}
purposesList={currentCmpMetadata?.purposesList ?? []}
vendorCount={currentCmpMetadata?.vendorCount ?? 0}
locale={locale}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright 2026 Content Pass GmbH. All Rights Reserved.
import type { CmpAdapter } from '@contentpass/react-native-contentpass';
import {
loadCmpMetadata,
observeCmpConsentStatus,
} from './ContentpassConsentGateStartup';

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});

return { promise, resolve };
}

describe('loadCmpMetadata', () => {
it('waits until purposes and vendor count are both available', async () => {
const purposes = deferred<string[]>();
const vendors = deferred<number>();
const cmpAdapter = {
getRequiredPurposes: jest.fn(() => purposes.promise),
getNumberOfVendors: jest.fn(() => vendors.promise),
} as unknown as CmpAdapter;
const metadata = loadCmpMetadata(cmpAdapter);
const onLoaded = jest.fn();
metadata.then(onLoaded);

purposes.resolve(['storage', 'analytics']);
await Promise.resolve();
expect(onLoaded).not.toHaveBeenCalled();

vendors.resolve(42);

await expect(metadata).resolves.toEqual({
purposesList: ['storage', 'analytics'],
vendorCount: 42,
});
});
});

describe('observeCmpConsentStatus', () => {
it('reports the initial consent snapshot before the gate resolves', async () => {
const initialConsent = deferred<boolean>();
const onStatus = jest.fn();
const cmpAdapter = {
hasFullConsent: jest.fn(() => initialConsent.promise),
onConsentStatusChange: jest.fn(),
} as unknown as CmpAdapter;

observeCmpConsentStatus(cmpAdapter, onStatus, jest.fn());

expect(onStatus).not.toHaveBeenCalled();

initialConsent.resolve(true);
await Promise.resolve();

expect(onStatus).toHaveBeenCalledWith(true);
});

it('ignores an initial snapshot superseded by a consent event', async () => {
const initialConsent = deferred<boolean>();
const onStatus = jest.fn();
const unsubscribe = jest.fn();
let emitConsentStatus!: (hasFullConsent: boolean) => void;
const cmpAdapter = {
hasFullConsent: jest.fn(() => initialConsent.promise),
onConsentStatusChange: jest.fn((listener) => {
emitConsentStatus = listener;
return unsubscribe;
}),
} as unknown as CmpAdapter;
const stopObserving = observeCmpConsentStatus(
cmpAdapter,
onStatus,
jest.fn()
);

emitConsentStatus(false);
initialConsent.resolve(true);
await Promise.resolve();

expect(onStatus).toHaveBeenCalledTimes(1);
expect(onStatus).toHaveBeenCalledWith(false);

stopObserving();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright 2026 Content Pass GmbH. All Rights Reserved.
import type { CmpAdapter } from '@contentpass/react-native-contentpass';

export type CmpMetadata = {
purposesList: string[];
vendorCount: number;
};

export async function loadCmpMetadata(
cmpAdapter: CmpAdapter
): Promise<CmpMetadata> {
const [purposesList, vendorCount] = await Promise.all([
cmpAdapter.getRequiredPurposes(),
cmpAdapter.getNumberOfVendors(),
]);

return { purposesList, vendorCount };
}

export function observeCmpConsentStatus(
cmpAdapter: CmpAdapter,
onStatus: (hasFullConsent: boolean) => void,
onError: (error: unknown) => void
): () => void {
let active = true;
let statusRevision = 0;
const initialStatusRevision = statusRevision;
const unsubscribe = cmpAdapter.onConsentStatusChange((hasFullConsent) => {
statusRevision += 1;
if (active) {
onStatus(hasFullConsent);
}
});

cmpAdapter
.hasFullConsent()
.then((hasFullConsent) => {
if (active && statusRevision === initialStatusRevision) {
onStatus(hasFullConsent);
}
})
.catch((error) => {
if (active && statusRevision === initialStatusRevision) {
onError(error);
}
});

return () => {
active = false;
unsubscribe?.();
};
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { runInNewContext } from 'node:vm';
import { EARLY_INJECT_JS } from './ContentpassLayer';
import { EARLY_INJECT_JS, layerReadyReducer } from './ContentpassLayer';

jest.mock('react-native-webview', () => ({
WebView: 'WebView',
Expand Down Expand Up @@ -46,6 +46,19 @@ function executeEarlyInjection({
});
}

describe('ContentpassLayer', () => {
it('stays visible when load-start arrives after the ready message', () => {
const readyAfterMessage = layerReadyReducer(false, 'first-layer-ready');
const readyAfterLoadStart = layerReadyReducer(
readyAfterMessage,
'load-started'
);

expect(readyAfterLoadStart).toBe(true);
expect(layerReadyReducer(readyAfterLoadStart, 'url-changed')).toBe(false);
});
});

describe('EARLY_INJECT_JS', () => {
it('queues messages until the Android bridge becomes available', () => {
const originalPostMessage = jest.fn();
Expand Down
Loading
Loading