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.9

### Patch Changes

- Updated dependencies [e34a7c9]
- @contentpass/react-native-contentpass-ui@0.8.0

## 0.0.8

### 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.8",
"version": "0.0.9",
"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.8.0

### Minor Changes

- e34a7c9: Reload the first layer after a network error and recover the consent gate from SDK ERROR when the app returns to the foreground.

## 0.7.1

### Patch 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.1",
"version": "0.8.0",
"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
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';
import { ActivityIndicator, AppState, StyleSheet, View } from 'react-native';
import {
ContentpassStateType,
useContentpassSdk,
Expand All @@ -16,6 +16,10 @@ import {
observeCmpConsentStatus,
type CmpMetadata,
} from './ContentpassConsentGateStartup';
import {
isConsentGateWaitingForAuth,
shouldRecoverFromErrorOnAppState,
} from './ContentpassConsentGateRecovery';

type ContentpassConsentGateProps = {
children: React.ReactNode;
Expand Down Expand Up @@ -106,9 +110,15 @@ export default function ContentpassConsentGate({
return;
}

cmpAdapter?.waitForInit?.().then(() => {
setCmpReady(true);
});
cmpAdapter?.waitForInit?.().then(
() => {
setCmpReady(true);
},
(error) => {
console.error('Failed to wait for CMP init', error);
setCmpReady(true);
}
);
}, [cmpReady, cmpAdapter]);

// Listen for consent status changes
Expand Down Expand Up @@ -139,6 +149,13 @@ export default function ContentpassConsentGate({
})
.catch((error) => {
console.error('Failed to load CMP metadata', error);
if (active) {
setCmpMetadata({
purposesList: [],
vendorCount: 0,
adapter: cmpAdapter,
});
}
});

return () => {
Expand All @@ -155,18 +172,24 @@ export default function ContentpassConsentGate({
});
}, [sdk]);

useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState) => {
if (shouldRecoverFromErrorOnAppState(nextState, cpAuthState?.state)) {
sdk.recoverFromError();
}
});

return () => subscription.remove();
}, [cpAuthState?.state, sdk]);

// Policy for setting the visibility of the consent layer
useEffect(() => {
const invalidStates = [
ContentpassStateType.INITIALISING,
ContentpassStateType.ERROR,
];
if (
!cmpReady ||
!currentCmpMetadata ||
!currentCmpConsentStatus ||
!cpAuthState ||
invalidStates.includes(cpAuthState.state)
isConsentGateWaitingForAuth(cpAuthState.state)
) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright 2026 Content Pass GmbH. All Rights Reserved.
import type { ContentpassStateType } from '@contentpass/react-native-contentpass';
import {
isConsentGateWaitingForAuth,
shouldRecoverFromErrorOnAppState,
} from './ContentpassConsentGateRecovery';

describe('isConsentGateWaitingForAuth', () => {
it('only waits while the SDK is initialising', () => {
expect(
isConsentGateWaitingForAuth('INITIALISING' as ContentpassStateType)
).toBe(true);
expect(isConsentGateWaitingForAuth('ERROR' as ContentpassStateType)).toBe(
false
);
expect(
isConsentGateWaitingForAuth('UNAUTHENTICATED' as ContentpassStateType)
).toBe(false);
expect(
isConsentGateWaitingForAuth('AUTHENTICATED' as ContentpassStateType)
).toBe(false);
});
});

describe('shouldRecoverFromErrorOnAppState', () => {
it('recovers from ERROR when the app returns to the foreground', () => {
expect(
shouldRecoverFromErrorOnAppState(
'active',
'ERROR' as ContentpassStateType
)
).toBe(true);
});

it('does not recover for other states or backgrounding', () => {
expect(
shouldRecoverFromErrorOnAppState(
'background',
'ERROR' as ContentpassStateType
)
).toBe(false);
expect(
shouldRecoverFromErrorOnAppState(
'active',
'UNAUTHENTICATED' as ContentpassStateType
)
).toBe(false);
expect(shouldRecoverFromErrorOnAppState('active')).toBe(false);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright 2026 Content Pass GmbH. All Rights Reserved.
import type { ContentpassStateType } from '@contentpass/react-native-contentpass';
import type { AppStateStatus } from 'react-native';

export function isConsentGateWaitingForAuth(
state: ContentpassStateType
): boolean {
return state === 'INITIALISING';
}

export function shouldRecoverFromErrorOnAppState(
nextState: AppStateStatus,
authState?: ContentpassStateType
): boolean {
return nextState === 'active' && authState === 'ERROR';
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ActivityIndicator,
AppState,
Modal,
Pressable,
StyleSheet,
Expand All @@ -9,6 +10,12 @@
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
import type { ContentpassLayerEvents } from './ContentpassLayerEvents';
import buildFirstLayerUrl from './buildFirstLayerUrl';
import {
canReachLayerUrl,
getLayerLoadErrorCopy,
LAYER_REACHABILITY_POLL_MS,
shouldRetryLayerLoadOnAppState,
} from './ContentpassLayerLoadRecovery';
import { useCallback, useEffect, useMemo, useReducer, useState } from 'react';

const MESSAGE_PROTOCOL = 'contentpass-first-layer';
Expand Down Expand Up @@ -139,6 +146,16 @@
fontSize: 14,
textAlign: 'center',
},
retryButton: {
marginTop: 16,
paddingHorizontal: 16,
paddingVertical: 10,
},
retryButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#007AFF',
},
loading: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
Expand Down Expand Up @@ -203,12 +220,54 @@
const [ready, updateReady] = useReducer(layerReadyReducer, false);
const [layerUrl, setLayerUrl] = useState(firstLayerUrl);
const [popupUrl, setPopupUrl] = useState<string | null>(null);
const [hasLoadError, setHasLoadError] = useState(false);
const [reloadNonce, setReloadNonce] = useState(0);
const errorCopy = getLayerLoadErrorCopy(locale);

const retryLoad = useCallback(() => {
setHasLoadError(false);
updateReady('url-changed');
setReloadNonce((nonce) => nonce + 1);
}, []);

useEffect(() => {
setLayerUrl(firstLayerUrl);
setHasLoadError(false);
updateReady('url-changed');
}, [firstLayerUrl]);

useEffect(() => {
const subscription = AppState.addEventListener('change', (nextState) => {
if (shouldRetryLayerLoadOnAppState(nextState, hasLoadError)) {
retryLoad();
}
});

return () => subscription.remove();
}, [hasLoadError, retryLoad]);

useEffect(() => {
if (!hasLoadError) {
return;
}

let cancelled = false;
const poll = async () => {
const reachable = await canReachLayerUrl(firstLayerUrl);
if (!cancelled && reachable) {
retryLoad();
}
};
const timer = setInterval(() => {
poll();
}, LAYER_REACHABILITY_POLL_MS);

return () => {
cancelled = true;
clearInterval(timer);
};
}, [firstLayerUrl, hasLoadError, retryLoad]);

const closePopup = useCallback(() => setPopupUrl(null), []);

const isFirstLayerUrl = useCallback(
Expand Down Expand Up @@ -236,7 +295,7 @@
}

try {
const popupUrl = new URL(url, baseUrl);

Check warning on line 298 in packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx

View workflow job for this annotation

GitHub Actions / lint

'popupUrl' is already declared in the upper scope on line 222 column 10

if (!POPUP_URL_PROTOCOLS.has(popupUrl.protocol)) {
console.warn('Unable to open popup with unsupported URL', url);
Expand Down Expand Up @@ -336,6 +395,7 @@
return (
<View style={styles.container}>
<WebView
key={reloadNonce}
source={{ uri: layerUrl }}
style={[styles.webview, !ready && { opacity: 0 }]}
originWhitelist={['*']}
Expand Down Expand Up @@ -400,6 +460,7 @@
}}
onLoadStart={() => {
console.debug('WebView load start');
setHasLoadError(false);
updateReady('load-started');
}}
onLoadEnd={() => {
Expand All @@ -410,19 +471,26 @@
}}
onError={(event) => {
console.debug('WebView error', event.nativeEvent);
setHasLoadError(true);
}}
onHttpError={(event) => {
console.debug('WebView HTTP error', event.nativeEvent);
}}
renderError={(errorDomain, errorCode, errorDesc) => (
renderError={() => (
<View style={styles.error}>
<Text style={styles.errorText}>
{`WebView error (${errorDomain}:${errorCode}) ${errorDesc}`}
</Text>
<Text style={styles.errorText}>{errorCopy.message}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={errorCopy.retryLabel}
onPress={retryLoad}
style={styles.retryButton}
>
<Text style={styles.retryButtonText}>{errorCopy.retryLabel}</Text>
</Pressable>
</View>
)}
/>
{!ready && (
{!ready && !hasLoadError && (
<View style={styles.loading}>
<ActivityIndicator size="large" />
</View>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2026 Content Pass GmbH. All Rights Reserved.
import {
canReachLayerUrl,
getLayerLoadErrorCopy,
shouldRetryLayerLoadOnAppState,
} from './ContentpassLayerLoadRecovery';

describe('getLayerLoadErrorCopy', () => {
it('returns German copy for de locales', () => {
expect(getLayerLoadErrorCopy('de')).toEqual({
message:
'Die Seite konnte nicht geladen werden. Prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.',
retryLabel: 'Erneut versuchen',
});
expect(getLayerLoadErrorCopy('de-DE').retryLabel).toBe('Erneut versuchen');
});

it('returns English copy for other locales', () => {
expect(getLayerLoadErrorCopy().retryLabel).toBe('Try again');
expect(getLayerLoadErrorCopy('en-GB').retryLabel).toBe('Try again');
expect(getLayerLoadErrorCopy('fr').retryLabel).toBe('Try again');
});
});

describe('shouldRetryLayerLoadOnAppState', () => {
it('retries when returning to the foreground after a load error', () => {
expect(shouldRetryLayerLoadOnAppState('active', true)).toBe(true);
});

it('does not retry while the app stays in the background', () => {
expect(shouldRetryLayerLoadOnAppState('background', true)).toBe(false);
expect(shouldRetryLayerLoadOnAppState('inactive', true)).toBe(false);
});

it('does not retry when the layer loaded', () => {
expect(shouldRetryLayerLoadOnAppState('active', false)).toBe(false);
});
});

describe('canReachLayerUrl', () => {
it('returns true for a successful fetch', async () => {
const fetchImpl = jest.fn().mockResolvedValue({ ok: true });

await expect(
canReachLayerUrl('https://example.com/layer', fetchImpl)
).resolves.toBe(true);
expect(fetchImpl).toHaveBeenCalledWith('https://example.com/layer', {
method: 'GET',
signal: expect.any(AbortSignal),
});
});

it('returns false when the request fails or is not ok', async () => {
await expect(
canReachLayerUrl('https://example.com/layer', () =>
Promise.reject(new Error('offline'))
)
).resolves.toBe(false);

await expect(
canReachLayerUrl('https://example.com/layer', () =>
Promise.resolve({ ok: false } as Response)
)
).resolves.toBe(false);
});
});
Loading
Loading