diff --git a/examples/consentmanager/CHANGELOG.md b/examples/consentmanager/CHANGELOG.md index 2d09506..a4af487 100644 --- a/examples/consentmanager/CHANGELOG.md +++ b/examples/consentmanager/CHANGELOG.md @@ -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 diff --git a/examples/consentmanager/package.json b/examples/consentmanager/package.json index 3c36ddd..a0880f3 100644 --- a/examples/consentmanager/package.json +++ b/examples/consentmanager/package.json @@ -1,6 +1,6 @@ { "name": "@contentpass/examples-consentmanager", - "version": "0.0.6", + "version": "0.0.7", "main": "index.ts", "scripts": { "start": "expo start", diff --git a/packages/react-native-contentpass-ui/CHANGELOG.md b/packages/react-native-contentpass-ui/CHANGELOG.md index de2f599..0df006d 100644 --- a/packages/react-native-contentpass-ui/CHANGELOG.md +++ b/packages/react-native-contentpass-ui/CHANGELOG.md @@ -1,5 +1,11 @@ # @contentpass/react-native-contentpass-ui +## 0.7.1 + +### Patch Changes + +- Fix iOS ready race + ## 0.7.0 ### Minor Changes diff --git a/packages/react-native-contentpass-ui/package.json b/packages/react-native-contentpass-ui/package.json index 138c9a5..c2d514a 100644 --- a/packages/react-native-contentpass-ui/package.json +++ b/packages/react-native-contentpass-ui/package.json @@ -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", diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx index fb37c4b..324d7e2 100644 --- a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx +++ b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx @@ -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; @@ -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(null); const [isShowingSecondLayer, setIsShowingSecondLayer] = useState(false); const [isShowingContentpass, setIsShowingContentpass] = useState(false); const [consentResolved, setConsentResolved] = useState(false); - const [purposesList, setPurposesList] = useState([]); - 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 { @@ -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]); @@ -147,6 +163,8 @@ export default function ContentpassConsentGate({ ]; if ( !cmpReady || + !currentCmpMetadata || + !currentCmpConsentStatus || !cpAuthState || invalidStates.includes(cpAuthState.state) ) { @@ -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, @@ -179,8 +197,9 @@ export default function ContentpassConsentGate({ setConsentResolved(true); }, [ cmpReady, + currentCmpMetadata, + currentCmpConsentStatus, cpAuthState, - hasFullConsent, isShowingContentpass, isShowingSecondLayer, isVisible, @@ -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} /> ); diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateStartup.test.ts b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateStartup.test.ts new file mode 100644 index 0000000..d468632 --- /dev/null +++ b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateStartup.test.ts @@ -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() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + + return { promise, resolve }; +} + +describe('loadCmpMetadata', () => { + it('waits until purposes and vendor count are both available', async () => { + const purposes = deferred(); + const vendors = deferred(); + 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(); + 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(); + 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); + }); +}); diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateStartup.ts b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateStartup.ts new file mode 100644 index 0000000..8e186f3 --- /dev/null +++ b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateStartup.ts @@ -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 { + 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?.(); + }; +} diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.test.ts b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.test.ts index 288dd1f..c4dbca6 100644 --- a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.test.ts +++ b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.test.ts @@ -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', @@ -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(); diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx index 25f9cb0..95ea8d4 100644 --- a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx +++ b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx @@ -9,11 +9,27 @@ import { import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import type { ContentpassLayerEvents } from './ContentpassLayerEvents'; import buildFirstLayerUrl from './buildFirstLayerUrl'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; const MESSAGE_PROTOCOL = 'contentpass-first-layer'; const POPUP_URL_PROTOCOLS = new Set(['http:', 'https:']); +type LayerReadyAction = 'first-layer-ready' | 'load-started' | 'url-changed'; + +export function layerReadyReducer( + ready: boolean, + action: LayerReadyAction +): boolean { + switch (action) { + case 'first-layer-ready': + return true; + case 'url-changed': + return false; + case 'load-started': + return ready; + } +} + function normalizePathname(pathname: string): string { return pathname.replace(/\/+$/, ''); } @@ -184,13 +200,13 @@ export default function ContentpassLayer({ }); }, [baseUrl, planId, propertyId, purposesList, vendorCount, locale]); - const [ready, setReady] = useState(false); + const [ready, updateReady] = useReducer(layerReadyReducer, false); const [layerUrl, setLayerUrl] = useState(firstLayerUrl); const [popupUrl, setPopupUrl] = useState(null); useEffect(() => { setLayerUrl(firstLayerUrl); - setReady(false); + updateReady('url-changed'); }, [firstLayerUrl]); const closePopup = useCallback(() => setPopupUrl(null), []); @@ -208,7 +224,7 @@ export default function ContentpassLayer({ ); const loadLayerUrl = useCallback((url: URL) => { - setReady(false); + updateReady('url-changed'); setLayerUrl(url.toString()); }, []); @@ -262,7 +278,7 @@ export default function ContentpassLayer({ switch (msg.action) { case 'FIRST_LAYER_READY': - setReady(true); + updateReady('first-layer-ready'); break; case 'ENABLE_SCROLL_ON_PROPERTY': case 'DISABLE_SCROLL_ON_PROPERTY': @@ -384,7 +400,7 @@ export default function ContentpassLayer({ }} onLoadStart={() => { console.debug('WebView load start'); - setReady(false); + updateReady('load-started'); }} onLoadEnd={() => { console.debug('WebView load end');