From e34a7c91f06c11dbd3dd2aafee0cfaa866ad6192 Mon Sep 17 00:00:00 2001 From: Maximilian Haupt Date: Wed, 26 Aug 2026 15:11:21 +0200 Subject: [PATCH 1/2] fix: recover first layer after a network error An offline WebView load left a dead-end overlay that stayed up after connectivity returned. Retry on tap, foreground, and reachability so users do not have to restart the app. --- .../reload-first-layer-after-network-error.md | 5 ++ .../src/components/ContentpassConsentGate.tsx | 41 +++++++--- .../ContentpassConsentGateRecovery.test.ts | 50 ++++++++++++ .../ContentpassConsentGateRecovery.ts | 16 ++++ .../src/components/ContentpassLayer.tsx | 78 +++++++++++++++++-- .../ContentpassLayerLoadRecovery.test.ts | 66 ++++++++++++++++ .../ContentpassLayerLoadRecovery.ts | 56 +++++++++++++ 7 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 .changeset/reload-first-layer-after-network-error.md create mode 100644 packages/react-native-contentpass-ui/src/components/ContentpassConsentGateRecovery.test.ts create mode 100644 packages/react-native-contentpass-ui/src/components/ContentpassConsentGateRecovery.ts create mode 100644 packages/react-native-contentpass-ui/src/components/ContentpassLayerLoadRecovery.test.ts create mode 100644 packages/react-native-contentpass-ui/src/components/ContentpassLayerLoadRecovery.ts diff --git a/.changeset/reload-first-layer-after-network-error.md b/.changeset/reload-first-layer-after-network-error.md new file mode 100644 index 0000000..3352b93 --- /dev/null +++ b/.changeset/reload-first-layer-after-network-error.md @@ -0,0 +1,5 @@ +--- +'@contentpass/react-native-contentpass-ui': patch +--- + +Reload the first layer after a network error and recover the consent gate from SDK ERROR when the app returns to the foreground. diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx index 324d7e2..6b7d9d2 100644 --- a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx +++ b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx @@ -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, @@ -16,6 +16,10 @@ import { observeCmpConsentStatus, type CmpMetadata, } from './ContentpassConsentGateStartup'; +import { + isConsentGateWaitingForAuth, + shouldRecoverFromErrorOnAppState, +} from './ContentpassConsentGateRecovery'; type ContentpassConsentGateProps = { children: React.ReactNode; @@ -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 @@ -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 () => { @@ -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; } diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateRecovery.test.ts b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateRecovery.test.ts new file mode 100644 index 0000000..c6b9e44 --- /dev/null +++ b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateRecovery.test.ts @@ -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); + }); +}); diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateRecovery.ts b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateRecovery.ts new file mode 100644 index 0000000..3661198 --- /dev/null +++ b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateRecovery.ts @@ -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'; +} diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx index 95ea8d4..2b490d2 100644 --- a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx +++ b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx @@ -1,5 +1,6 @@ import { ActivityIndicator, + AppState, Modal, Pressable, StyleSheet, @@ -9,6 +10,12 @@ import { 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'; @@ -139,6 +146,16 @@ const styles = StyleSheet.create({ fontSize: 14, textAlign: 'center', }, + retryButton: { + marginTop: 16, + paddingHorizontal: 16, + paddingVertical: 10, + }, + retryButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#007AFF', + }, loading: { ...StyleSheet.absoluteFillObject, alignItems: 'center', @@ -203,12 +220,54 @@ export default function ContentpassLayer({ const [ready, updateReady] = useReducer(layerReadyReducer, false); const [layerUrl, setLayerUrl] = useState(firstLayerUrl); const [popupUrl, setPopupUrl] = useState(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( @@ -336,6 +395,7 @@ export default function ContentpassLayer({ return ( { console.debug('WebView load start'); + setHasLoadError(false); updateReady('load-started'); }} onLoadEnd={() => { @@ -410,19 +471,26 @@ export default function ContentpassLayer({ }} onError={(event) => { console.debug('WebView error', event.nativeEvent); + setHasLoadError(true); }} onHttpError={(event) => { console.debug('WebView HTTP error', event.nativeEvent); }} - renderError={(errorDomain, errorCode, errorDesc) => ( + renderError={() => ( - - {`WebView error (${errorDomain}:${errorCode}) ${errorDesc}`} - + {errorCopy.message} + + {errorCopy.retryLabel} + )} /> - {!ready && ( + {!ready && !hasLoadError && ( diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassLayerLoadRecovery.test.ts b/packages/react-native-contentpass-ui/src/components/ContentpassLayerLoadRecovery.test.ts new file mode 100644 index 0000000..0318126 --- /dev/null +++ b/packages/react-native-contentpass-ui/src/components/ContentpassLayerLoadRecovery.test.ts @@ -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); + }); +}); diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassLayerLoadRecovery.ts b/packages/react-native-contentpass-ui/src/components/ContentpassLayerLoadRecovery.ts new file mode 100644 index 0000000..0238d3b --- /dev/null +++ b/packages/react-native-contentpass-ui/src/components/ContentpassLayerLoadRecovery.ts @@ -0,0 +1,56 @@ +// Copyright 2026 Content Pass GmbH. All Rights Reserved. +import type { AppStateStatus } from 'react-native'; + +export const LAYER_REACHABILITY_POLL_MS = 3000; +export const LAYER_REACHABILITY_TIMEOUT_MS = 4000; + +export type LayerLoadErrorCopy = { + message: string; + retryLabel: string; +}; + +export function getLayerLoadErrorCopy(locale?: string): LayerLoadErrorCopy { + const language = locale?.split(/[-_]/)[0]?.toLowerCase(); + + if (language === 'de') { + return { + message: + 'Die Seite konnte nicht geladen werden. Prüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.', + retryLabel: 'Erneut versuchen', + }; + } + + return { + message: + 'This page could not be loaded. Check your internet connection and try again.', + retryLabel: 'Try again', + }; +} + +export function shouldRetryLayerLoadOnAppState( + nextState: AppStateStatus, + hasLoadError: boolean +): boolean { + return hasLoadError && nextState === 'active'; +} + +export async function canReachLayerUrl( + url: string, + fetchImpl: typeof fetch = fetch, + timeoutMs = LAYER_REACHABILITY_TIMEOUT_MS +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchImpl(url, { + method: 'GET', + signal: controller.signal, + }); + return response.ok; + } catch { + return false; + } finally { + clearTimeout(timer); + } +} From e32bd90f9b17a79e05b66dd06c8ef3c87347b919 Mon Sep 17 00:00:00 2001 From: Maximilian Haupt Date: Wed, 26 Aug 2026 15:16:12 +0200 Subject: [PATCH 2/2] chore: bump versions --- .changeset/reload-first-layer-after-network-error.md | 5 ----- examples/consentmanager/CHANGELOG.md | 7 +++++++ examples/consentmanager/package.json | 2 +- packages/react-native-contentpass-ui/CHANGELOG.md | 6 ++++++ packages/react-native-contentpass-ui/package.json | 2 +- 5 files changed, 15 insertions(+), 7 deletions(-) delete mode 100644 .changeset/reload-first-layer-after-network-error.md diff --git a/.changeset/reload-first-layer-after-network-error.md b/.changeset/reload-first-layer-after-network-error.md deleted file mode 100644 index 3352b93..0000000 --- a/.changeset/reload-first-layer-after-network-error.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@contentpass/react-native-contentpass-ui': patch ---- - -Reload the first layer after a network error and recover the consent gate from SDK ERROR when the app returns to the foreground. diff --git a/examples/consentmanager/CHANGELOG.md b/examples/consentmanager/CHANGELOG.md index 5863fd9..9e89500 100644 --- a/examples/consentmanager/CHANGELOG.md +++ b/examples/consentmanager/CHANGELOG.md @@ -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 diff --git a/examples/consentmanager/package.json b/examples/consentmanager/package.json index f0cac73..fa8eeaa 100644 --- a/examples/consentmanager/package.json +++ b/examples/consentmanager/package.json @@ -1,6 +1,6 @@ { "name": "@contentpass/examples-consentmanager", - "version": "0.0.8", + "version": "0.0.9", "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 0df006d..885df2e 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.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 diff --git a/packages/react-native-contentpass-ui/package.json b/packages/react-native-contentpass-ui/package.json index c2d514a..49566d8 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.1", + "version": "0.8.0", "description": "Contentpass React Native UI Components", "source": "./src/index.tsx", "main": "./lib/commonjs/index.js",