diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml
index 5119e93..d39fdc4 100644
--- a/.github/workflows/e2e-ios.yml
+++ b/.github/workflows/e2e-ios.yml
@@ -34,27 +34,19 @@ jobs:
# setup and build, so every flow that runs against the example's
# default config shares the first one, and only flows that need Metro
# started with other EXPO_PUBLIC_* values get a job of their own.
- #
- # layer_page_load_timeout_ms gives the layer's page longer to load
- # from live staging than the SDK's 8s default; cmp-timeout-fail-open
- # forces every timeout down instead, and the CMP-failure flow never
- # gets as far as the layer.
include:
- scenario: default-config
flows: happy-path cancelled-login deny-all consent-survives-relaunch
onetrust_app_id: ''
gate_timeout_ms: ''
- layer_page_load_timeout_ms: '30000'
- scenario: onetrust-adapter-failure
flows: onetrust-adapter-failure
onetrust_app_id: 'e2e-invalid-app-id'
gate_timeout_ms: ''
- layer_page_load_timeout_ms: ''
- scenario: cmp-timeout-fail-open
flows: cmp-timeout-fail-open
onetrust_app_id: ''
gate_timeout_ms: '1'
- layer_page_load_timeout_ms: ''
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -142,7 +134,6 @@ jobs:
env:
EXPO_PUBLIC_ONETRUST_APP_ID: ${{ matrix.onetrust_app_id }}
EXPO_PUBLIC_GATE_TIMEOUT_MS: ${{ matrix.gate_timeout_ms }}
- EXPO_PUBLIC_LAYER_PAGE_LOAD_TIMEOUT_MS: ${{ matrix.layer_page_load_timeout_ms }}
# A Debug build fetches its JS bundle from Metro at app-launch time,
# so the env vars that pick the per-flow scenario have to be set on
# the Metro process, not on the (now scenario-independent) build
diff --git a/examples/onetrust/e2e/README.md b/examples/onetrust/e2e/README.md
index af5b930..8b07934 100644
--- a/examples/onetrust/e2e/README.md
+++ b/examples/onetrust/e2e/README.md
@@ -79,10 +79,6 @@ to tolerate what fails there without the code being at fault:
retry turns the job green with a warning annotation naming the flows, and
still uploads the debug output, Metro's log included, so the flake can be
looked into.
-- The `default-config` job starts Metro with
- `EXPO_PUBLIC_LAYER_PAGE_LOAD_TIMEOUT_MS=30000`, so a slow page from
- staging doesn't fail the gate open halfway through a flow; the SDK's
- default is 8s.
- The simulator boots at the start of the job and settles while the app
builds. It is pinned (`SIM_DEVICE`, `SIM_RUNTIME`), as is Maestro
(`MAESTRO_VERSION`), in the workflow's job `env`.
diff --git a/examples/onetrust/e2e/flows/happy-path.yaml b/examples/onetrust/e2e/flows/happy-path.yaml
index d97f3c0..a2ed3dc 100644
--- a/examples/onetrust/e2e/flows/happy-path.yaml
+++ b/examples/onetrust/e2e/flows/happy-path.yaml
@@ -53,10 +53,10 @@ appId: com.contentpassOnetrustExample.onetrustExample
- tapOn: 'Please log in'
- tapOn: 'PASSWORD'
- inputText: ${E2E_STAGING_PASSWORD}
-# Anchored exact match: the page's own "Please log in" heading contains
-# "Log in" as a substring too, and an unanchored match landed on that
-# non-interactive heading instead of the submit button.
-- tapOn: '^Log in$'
+# Submit with the keyboard's return key, not a tap on "Log in": the button
+# sits in a footer pinned to the bottom of the page, and with the keyboard
+# open its centre can fall on the keyboard's toolbar, which takes the tap.
+- pressKey: Enter
# iOS may offer to save the just-submitted password in a system alert that
# hides everything else from Maestro until it's dismissed.
- tapOn:
diff --git a/examples/onetrust/src/Config.ts b/examples/onetrust/src/Config.ts
index 21da959..d46975c 100644
--- a/examples/onetrust/src/Config.ts
+++ b/examples/onetrust/src/Config.ts
@@ -25,14 +25,6 @@ const forcedTimeoutMs = process.env.EXPO_PUBLIC_GATE_TIMEOUT_MS
? Number(process.env.EXPO_PUBLIC_GATE_TIMEOUT_MS)
: undefined;
-// Lets an E2E run against live staging give the layer's page longer to load
-// than the SDK's default, so a slow response doesn't fail the gate open
-// halfway through a flow that is testing something else.
-const layerPageLoadTimeoutMs = process.env
- .EXPO_PUBLIC_LAYER_PAGE_LOAD_TIMEOUT_MS
- ? Number(process.env.EXPO_PUBLIC_LAYER_PAGE_LOAD_TIMEOUT_MS)
- : undefined;
-
export const GATE_TIMEOUTS: ContentpassGateTimeouts | undefined =
forcedTimeoutMs
? {
@@ -45,6 +37,4 @@ export const GATE_TIMEOUTS: ContentpassGateTimeouts | undefined =
layerPageLoadTimeoutMs: forcedTimeoutMs,
layerReadyTimeoutMs: forcedTimeoutMs,
}
- : layerPageLoadTimeoutMs
- ? { layerPageLoadTimeoutMs }
- : undefined;
+ : undefined;
diff --git a/packages/react-native-contentpass-ui/CHANGELOG.md b/packages/react-native-contentpass-ui/CHANGELOG.md
index 4ef09ae..a96e8c8 100644
--- a/packages/react-native-contentpass-ui/CHANGELOG.md
+++ b/packages/react-native-contentpass-ui/CHANGELOG.md
@@ -1,5 +1,13 @@
# @contentpass/react-native-contentpass-ui
+## 0.11.0
+
+### Minor Changes
+
+- [#82](https://github.com/contentpass/react-native-contentpass/pull/82) [`6d230b2`](https://github.com/contentpass/react-native-contentpass/commit/6d230b2b843875504b276b28ec882195d865dd19) Thanks [@0x7f](https://github.com/0x7f)! - Call `onVisibilityChange(false)` when the gate fails open while the consent layer is shown, so an app that hides its content while the layer is visible shows it again. `onVisibilityChange` is now only called when the visibility it last reported changes. Every fail-open is reported to Contentpass's error tracking, tagged with the UI package version, which needs `@contentpass/react-native-contentpass` 0.10.0 or later. The `timeouts` prop is now documented in the README.
+
+- [#82](https://github.com/contentpass/react-native-contentpass/pull/82) [`613ad69`](https://github.com/contentpass/react-native-contentpass/commit/613ad69fefb6e56c72749db803ef48569bff5b75) Thanks [@0x7f](https://github.com/0x7f)! - Give the consent layer's page 30 seconds to load before the gate fails open, instead of 8, the same default as every other gate timeout. The wait starts when the layer mounts and ends once the page and every resource it loads have arrived, so it includes starting the device's WebView for the first time. On fresh Android installs that often took longer than 8 seconds, and the gate failed open before the layer ever showed. `timeouts.layerPageLoadTimeoutMs` still overrides the default.
+
## 0.10.1
### Patch Changes
diff --git a/packages/react-native-contentpass-ui/README.md b/packages/react-native-contentpass-ui/README.md
index 413bebd..27811f8 100644
--- a/packages/react-native-contentpass-ui/README.md
+++ b/packages/react-native-contentpass-ui/README.md
@@ -36,7 +36,33 @@ A gate component that wraps your app content. It automatically shows the Content
| `contentpassConfig` | `ContentpassConfig` | Yes | — | The Contentpass SDK configuration object. |
| `hideAppWhenVisible` | `boolean` | No | `true` | When `true`, the app content is completely replaced by the consent layer. When `false`, the consent layer is rendered as an overlay on top of the app content. |
| `locale` | `string` | No | — | Forces the consent layer to be displayed in the given locale (e.g. `"de"`, `"es"`). When omitted, the layer picks the locale from the user's browser/device preferences and falls back to English. Unsupported values are ignored by the layer. |
-| `onVisibilityChange` | `(visible: boolean) => void` | No | — | Callback invoked when the consent layer visibility changes. |
+| `onVisibilityChange` | `(visible: boolean) => void` | No | — | Callback invoked when the consent layer visibility changes, including with `false` when the gate fails open while the layer is shown. |
+| `timeouts` | `ContentpassGateTimeouts` | No | 30s each | Overrides how long the gate waits for each step before it fails open. See [Timeouts and failing open](#timeouts-and-failing-open). |
+
+#### Timeouts and failing open
+
+When a step does not finish in time, or fails, the gate fails open: it renders `children` for the rest of the mounted session instead of blocking the app, and reports the failure to Contentpass. Every timeout defaults to 30 seconds and can be overridden on its own through `timeouts`:
+
+| Key | What the gate waits for |
+|-----|-------------------------|
+| `cmpInitTimeoutMs` | `cmpAdapter.waitForInit()` |
+| `cmpMetadataTimeoutMs` | `cmpAdapter.getRequiredPurposes()` and `getNumberOfVendors()` |
+| `cmpConsentStatusTimeoutMs` | The first `cmpAdapter.hasFullConsent()` |
+| `contentpassInitTimeoutMs` | The Contentpass SDK leaving its `INITIALISING` state |
+| `authenticateTimeoutMs` | The Contentpass login or signup flow |
+| `secondLayerTimeoutMs` | `cmpAdapter.showSecondLayer()` |
+| `layerPageLoadTimeoutMs` | The consent layer's page and every resource it loads, counted from when the layer mounts. On a fresh install this includes starting the device's WebView for the first time. |
+| `layerReadyTimeoutMs` | The loaded page reporting that it is ready |
+
+```tsx
+
+ {/* Your app content */}
+
+```
## Usage
@@ -78,7 +104,7 @@ For a full working example using OneTrust as the CMP, see the [`examples/onetrus
## Requirements
- React Native >= 0.76.0
-- `@contentpass/react-native-contentpass` (peer dependency)
+- `@contentpass/react-native-contentpass` >= 0.10.0 (peer dependency)
- `react-native-webview` (peer dependency)
## License
diff --git a/packages/react-native-contentpass-ui/package.json b/packages/react-native-contentpass-ui/package.json
index e7fe919..4b00c79 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.10.1",
+ "version": "0.11.0",
"description": "Contentpass React Native UI Components",
"source": "./src/index.tsx",
"main": "./lib/commonjs/index.js",
@@ -53,7 +53,7 @@
"access": "public"
},
"peerDependencies": {
- "@contentpass/react-native-contentpass": "*",
+ "@contentpass/react-native-contentpass": ">=0.10.0",
"react": "*",
"react-native": "*",
"react-native-webview": "*"
diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.test.tsx b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.test.tsx
new file mode 100644
index 0000000..1a12b00
--- /dev/null
+++ b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.test.tsx
@@ -0,0 +1,171 @@
+// Copyright 2026 Content Pass GmbH. All Rights Reserved.
+import { Text } from 'react-native';
+import { act, render, screen, waitFor } from '@testing-library/react-native';
+import type {
+ CmpAdapter,
+ ContentpassConfig,
+ ContentpassState,
+} from '@contentpass/react-native-contentpass';
+import ContentpassConsentGate from './ContentpassConsentGate';
+import { SDK_VERSION } from './sdkVersion';
+
+let mockLayerFailure: ((error: unknown) => void) | null = null;
+
+jest.mock('./ContentpassLayer', () => ({
+ __esModule: true,
+ default: ({ onFailure }: { onFailure: (error: unknown) => void }) => {
+ mockLayerFailure = onFailure;
+ return null;
+ },
+}));
+
+const mockSdk = {
+ instanceId: 'instance',
+ authState: null as ContentpassState | null,
+ authenticate: jest.fn(async () => {}),
+ event: jest.fn(async () => {}),
+ recoverFromError: jest.fn(async () => {}),
+ registerObserver: jest.fn((observer: (state: ContentpassState) => void) => {
+ if (mockSdk.authState) {
+ observer(mockSdk.authState);
+ }
+ }),
+ reportError: jest.fn(),
+ unregisterObserver: jest.fn(),
+};
+
+// The SDK package resolves to its build output, which tests run without.
+jest.mock(
+ '@contentpass/react-native-contentpass',
+ () => ({
+ ContentpassStateType: {
+ INITIALISING: 'INITIALISING',
+ UNAUTHENTICATED: 'UNAUTHENTICATED',
+ AUTHENTICATED: 'AUTHENTICATED',
+ ERROR: 'ERROR',
+ },
+ useContentpassSdk: () => mockSdk,
+ }),
+ { virtual: true }
+);
+
+const contentpassConfig = {
+ propertyId: 'property',
+ planId: 'plan',
+ issuer: 'https://issuer.test',
+ apiUrl: 'https://example.test',
+ redirectUrl: 'test://oauth',
+} as ContentpassConfig;
+
+function createCmpAdapter() {
+ let consentStatusCallback: ((fullConsent: boolean) => void) | null = null;
+ const cmpAdapter: CmpAdapter = {
+ acceptAll: jest.fn(async () => {}),
+ denyAll: jest.fn(async () => {}),
+ getNumberOfVendors: jest.fn(async () => 100),
+ getRequiredPurposes: jest.fn(async () => ['1', '2']),
+ hasFullConsent: jest.fn(async () => false),
+ onConsentStatusChange: jest.fn((callback) => {
+ consentStatusCallback = callback;
+ return () => {};
+ }),
+ showSecondLayer: jest.fn(async () => {}),
+ waitForInit: jest.fn(async () => {}),
+ };
+
+ return {
+ cmpAdapter,
+ changeConsentStatus: (fullConsent: boolean) =>
+ consentStatusCallback?.(fullConsent),
+ };
+}
+
+function renderGate(
+ cmpAdapter: CmpAdapter,
+ onVisibilityChange: (visible: boolean) => void
+) {
+ return render(
+
+ App content
+
+ );
+}
+
+describe('ContentpassConsentGate failing open', () => {
+ beforeEach(() => {
+ mockLayerFailure = null;
+ mockSdk.authState = {
+ state: 'UNAUTHENTICATED',
+ hasValidSubscription: false,
+ } as ContentpassState;
+ jest.spyOn(console, 'debug').mockImplementation(() => {});
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ jest.restoreAllMocks();
+ });
+
+ it('tells the host app the layer is gone and reports why when the layer fails', async () => {
+ const { cmpAdapter, changeConsentStatus } = createCmpAdapter();
+ const onVisibilityChange = jest.fn();
+ const { unmount } = renderGate(cmpAdapter, onVisibilityChange);
+ await waitFor(() => expect(mockLayerFailure).not.toBeNull());
+ expect(onVisibilityChange.mock.calls).toEqual([[true]]);
+
+ const error = new Error(
+ 'Timed out while loading the Contentpass layer page'
+ );
+ act(() => mockLayerFailure!(error));
+
+ expect(screen.getByText('App content')).toBeTruthy();
+ expect(onVisibilityChange.mock.calls).toEqual([[true], [false]]);
+ expect(mockSdk.reportError).toHaveBeenCalledWith(error, {
+ msg: 'Failed to initialize Contentpass layer',
+ tags: { uiVersion: SDK_VERSION },
+ });
+
+ act(() => changeConsentStatus(false));
+
+ expect(onVisibilityChange.mock.calls).toEqual([[true], [false]]);
+
+ unmount();
+ });
+
+ it('reports a CMP failure that is not an Error under the gate message', async () => {
+ const { cmpAdapter } = createCmpAdapter();
+ cmpAdapter.waitForInit = jest.fn(() => Promise.reject('not ready'));
+ const onVisibilityChange = jest.fn();
+ const { unmount } = renderGate(cmpAdapter, onVisibilityChange);
+
+ await screen.findByText('App content');
+
+ expect(mockSdk.reportError).toHaveBeenCalledWith(
+ new Error('Failed to initialize CMP'),
+ { msg: 'Failed to initialize CMP', tags: { uiVersion: SDK_VERSION } }
+ );
+ expect(onVisibilityChange).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('does not report the SDK ERROR state, which the SDK reports itself', async () => {
+ mockSdk.authState = {
+ state: 'ERROR',
+ error: new Error('Failed to initialise auth state'),
+ } as ContentpassState;
+ const { cmpAdapter } = createCmpAdapter();
+ const { unmount } = renderGate(cmpAdapter, jest.fn());
+
+ await screen.findByText('App content');
+
+ expect(mockSdk.reportError).not.toHaveBeenCalled();
+
+ unmount();
+ });
+});
diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx
index 72d3050..9f69c4a 100644
--- a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx
+++ b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.tsx
@@ -1,4 +1,10 @@
-import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
import { ActivityIndicator, AppState, StyleSheet, View } from 'react-native';
import {
ContentpassStateType,
@@ -24,6 +30,7 @@ import {
isConsentGateWaitingForAuth,
shouldRecoverFromErrorOnAppState,
} from './ContentpassConsentGateRecovery';
+import { SDK_VERSION } from './sdkVersion';
type ContentpassConsentGateProps = {
children: React.ReactNode;
@@ -74,10 +81,33 @@ export default function ContentpassConsentGate({
} | null>(null);
const currentCmpConsentStatus =
cmpConsentStatus?.adapter === cmpAdapter ? cmpConsentStatus : null;
- const failOpen = useCallback((message: string, error?: unknown) => {
- console.error(message, error);
- setFailedOpen(true);
- }, []);
+ const failOpen = useCallback(
+ (message: string, error?: unknown, { report = true } = {}) => {
+ console.error(message, error);
+ if (report) {
+ sdk.reportError(error instanceof Error ? error : new Error(message), {
+ msg: message,
+ tags: { uiVersion: SDK_VERSION },
+ });
+ }
+ setFailedOpen(true);
+ },
+ [sdk]
+ );
+ // What the host app was last told through `onVisibilityChange`, so it hears
+ // about every change exactly once, including the layer going away when the
+ // gate fails open.
+ const reportedVisibility = useRef(false);
+ const reportVisibility = useCallback(
+ (visible: boolean) => {
+ if (reportedVisibility.current === visible) {
+ return;
+ }
+ reportedVisibility.current = visible;
+ onVisibilityChange?.(visible);
+ },
+ [onVisibilityChange]
+ );
const handleLayerFailure = useCallback(
(error: unknown) => {
failOpen('Failed to initialize Contentpass layer', error);
@@ -247,7 +277,10 @@ export default function ContentpassConsentGate({
useEffect(() => {
if (cpAuthState?.state === ContentpassStateType.ERROR) {
- failOpen('Contentpass initialization failed', cpAuthState.error);
+ // The SDK has already reported the error that put it into ERROR.
+ failOpen('Contentpass initialization failed', cpAuthState.error, {
+ report: false,
+ });
return;
}
@@ -268,6 +301,7 @@ export default function ContentpassConsentGate({
// Policy for setting the visibility of the consent layer
useEffect(() => {
if (
+ failedOpen ||
!cmpReady ||
!currentCmpMetadata ||
!currentCmpConsentStatus ||
@@ -300,22 +334,27 @@ export default function ContentpassConsentGate({
isShowingSecondLayer,
visible,
});
- if (visible !== isVisible) {
- onVisibilityChange?.(visible);
- }
+ reportVisibility(visible);
setIsVisible(visible);
setConsentResolved(true);
}, [
+ failedOpen,
cmpReady,
currentCmpMetadata,
currentCmpConsentStatus,
cpAuthState,
isShowingContentpass,
isShowingSecondLayer,
- isVisible,
- onVisibilityChange,
+ reportVisibility,
]);
+ // Failing open renders the app in place of the layer.
+ useEffect(() => {
+ if (failedOpen) {
+ reportVisibility(false);
+ }
+ }, [failedOpen, reportVisibility]);
+
// The layer is triggered to display whenever it becomes visible, matching
// the point the web SDK fires `wall`/`show`: right before mounting the
// funnel UI, not once it has actually rendered (see `wall`/`rendered`,
diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateStartup.ts b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateStartup.ts
index b3b1479..64e0c6d 100644
--- a/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateStartup.ts
+++ b/packages/react-native-contentpass-ui/src/components/ContentpassConsentGateStartup.ts
@@ -19,7 +19,7 @@ export type ContentpassGateTimeouts = {
secondLayerTimeoutMs?: number;
/** Waiting for the Contentpass SDK to leave its initial `INITIALISING` state. */
contentpassInitTimeoutMs?: number;
- /** Waiting for the layer's static HTML page to finish loading. */
+ /** Waiting, from when the layer mounts, for its page and every resource the page loads to finish loading. */
layerPageLoadTimeoutMs?: number;
/** Waiting, once the layer page has loaded, for it to report ready. */
layerReadyTimeoutMs?: number;
diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx
index 3179b67..87d9782 100644
--- a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx
+++ b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx
@@ -44,14 +44,6 @@ type LayerReadyAction =
export const LOAD_END_READY_FALLBACK_MS = 500;
-// The static HTML shell is small and should arrive quickly even on a bad
-// connection; if it doesn't, there's no point waiting for the full budget
-// before failing - something more fundamental (DNS, TLS, no connectivity)
-// is wrong. Once the shell has loaded, the remaining wait is for the page's
-// own JS to finish initialising and report ready, which gets its own,
-// separately configurable budget (see `readyTimeoutMs` below).
-const LAYER_PAGE_LOAD_TIMEOUT_MS = 8_000;
-
function useAndroidOverlayNavigationBarInset(): number {
const window = useWindowDimensions();
@@ -313,7 +305,7 @@ export default function ContentpassLayer({
vendorCount,
locale,
onFailure,
- pageLoadTimeoutMs = LAYER_PAGE_LOAD_TIMEOUT_MS,
+ pageLoadTimeoutMs = UI_OPERATION_TIMEOUT_MS,
readyTimeoutMs = UI_OPERATION_TIMEOUT_MS,
}: {
baseUrl: string;
@@ -325,7 +317,7 @@ export default function ContentpassLayer({
vendorCount: number;
locale?: string;
onFailure: (error: unknown) => void;
- /** How long to wait for the static layer page to finish loading before failing. Defaults to {@link LAYER_PAGE_LOAD_TIMEOUT_MS}. */
+ /** How long to wait, from mount, for the layer page and every resource it loads to finish loading before failing. Defaults to {@link UI_OPERATION_TIMEOUT_MS}. */
pageLoadTimeoutMs?: number;
/** How long to wait, once the page has loaded, for it to report ready before failing. Defaults to {@link UI_OPERATION_TIMEOUT_MS}. */
readyTimeoutMs?: number;
@@ -423,7 +415,9 @@ export default function ContentpassLayer({
};
}, [firstLayerUrl, hasLoadError, retryLoad]);
- // Stage 1: the static HTML shell must finish loading quickly.
+ // Stage 1: the page has to finish loading. The clock starts at mount, so it
+ // also covers creating the WebView, and `onLoadEnd` waits for every resource
+ // the page loads (Android's `onPageFinished`), not just its HTML.
useEffect(() => {
if (pageLoaded) {
return;
@@ -438,7 +432,7 @@ export default function ContentpassLayer({
return () => clearTimeout(timeout);
}, [pageLoaded, onFailure, pageLoadTimeoutMs]);
- // Stage 2: once the shell has loaded, its own JS has the full budget to
+ // Stage 2: once the page has loaded, its own JS has the full budget to
// finish initialising and report FIRST_LAYER_READY.
useEffect(() => {
if (!pageLoaded || ready) {
@@ -710,7 +704,11 @@ export default function ContentpassLayer({
}}
onHttpError={(event) => {
console.debug('WebView HTTP error', event.nativeEvent);
- onFailure(event.nativeEvent);
+ onFailure(
+ new Error(
+ `Received HTTP ${event.nativeEvent.statusCode} while loading the Contentpass layer page`
+ )
+ );
}}
renderError={() => (
diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassLayerLoading.test.tsx b/packages/react-native-contentpass-ui/src/components/ContentpassLayerLoading.test.tsx
index cf5845a..688148f 100644
--- a/packages/react-native-contentpass-ui/src/components/ContentpassLayerLoading.test.tsx
+++ b/packages/react-native-contentpass-ui/src/components/ContentpassLayerLoading.test.tsx
@@ -1,7 +1,8 @@
// Copyright 2026 Content Pass GmbH. All Rights Reserved.
import { ActivityIndicator, StyleSheet } from 'react-native';
-import { render } from '@testing-library/react-native';
+import { act, render } from '@testing-library/react-native';
import ContentpassLayer from './ContentpassLayer';
+import { UI_OPERATION_TIMEOUT_MS } from './ContentpassConsentGateStartup';
import type { ContentpassLayerEvents } from './ContentpassLayerEvents';
jest.mock('react-native-webview', () => ({
@@ -51,3 +52,62 @@ describe('ContentpassLayer loading indicator', () => {
unmount();
});
});
+
+describe('ContentpassLayer page load', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.spyOn(console, 'debug').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ });
+
+ function renderLayer(onFailure: (error: unknown) => void) {
+ return render(
+
+ );
+ }
+
+ it('waits the full UI operation budget for a slow page before failing', () => {
+ const onFailure = jest.fn();
+ const { unmount } = renderLayer(onFailure);
+
+ act(() => jest.advanceTimersByTime(UI_OPERATION_TIMEOUT_MS - 1));
+ expect(onFailure).not.toHaveBeenCalled();
+
+ act(() => jest.advanceTimersByTime(1));
+ expect(onFailure).toHaveBeenCalledWith(
+ new Error('Timed out while loading the Contentpass layer page')
+ );
+
+ unmount();
+ });
+
+ it('fails with the status of an HTTP error on the page', () => {
+ const onFailure = jest.fn();
+ const { UNSAFE_getByType, unmount } = renderLayer(onFailure);
+
+ act(() =>
+ UNSAFE_getByType('WebView' as any).props.onHttpError({
+ nativeEvent: { statusCode: 503, description: 'Service Unavailable' },
+ })
+ );
+
+ expect(onFailure).toHaveBeenCalledWith(
+ new Error('Received HTTP 503 while loading the Contentpass layer page')
+ );
+
+ unmount();
+ });
+});
diff --git a/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.ts b/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.ts
index 9c99552..d3aefd5 100644
--- a/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.ts
+++ b/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.ts
@@ -1,9 +1,7 @@
-import packageJson from '../../package.json';
+import { SDK_VERSION } from './sdkVersion';
const THEME = 'classic-app';
-const SDK_VERSION = `react-native-contentpass-ui@${packageJson.version}`;
-
export default function buildFirstLayerUrl({
baseUrl,
propertyId,
diff --git a/packages/react-native-contentpass-ui/src/components/sdkVersion.ts b/packages/react-native-contentpass-ui/src/components/sdkVersion.ts
new file mode 100644
index 0000000..8dc8488
--- /dev/null
+++ b/packages/react-native-contentpass-ui/src/components/sdkVersion.ts
@@ -0,0 +1,4 @@
+// Copyright 2026 Content Pass GmbH. All Rights Reserved.
+import packageJson from '../../package.json';
+
+export const SDK_VERSION = `react-native-contentpass-ui@${packageJson.version}`;
diff --git a/packages/react-native-contentpass/CHANGELOG.md b/packages/react-native-contentpass/CHANGELOG.md
index d2cd6d5..114e44c 100644
--- a/packages/react-native-contentpass/CHANGELOG.md
+++ b/packages/react-native-contentpass/CHANGELOG.md
@@ -1,5 +1,11 @@
# @contentpass/react-native-contentpass
+## 0.10.0
+
+### Minor Changes
+
+- [#82](https://github.com/contentpass/react-native-contentpass/pull/82) [`25b99d9`](https://github.com/contentpass/react-native-contentpass/commit/25b99d9bf0ace8db82f3cbdf2b884ce46fa4b3f7) Thanks [@0x7f](https://github.com/0x7f)! - Add `reportError(error, { msg, tags })` to the SDK instance. It reports to the SDK's own error tracking, and `@contentpass/react-native-contentpass-ui` uses it to report why the consent gate failed open.
+
## 0.9.1
### Patch Changes
diff --git a/packages/react-native-contentpass/package.json b/packages/react-native-contentpass/package.json
index 346bdfe..21072fa 100644
--- a/packages/react-native-contentpass/package.json
+++ b/packages/react-native-contentpass/package.json
@@ -1,6 +1,6 @@
{
"name": "@contentpass/react-native-contentpass",
- "version": "0.9.1",
+ "version": "0.10.0",
"description": "Contentpass React Native SDK",
"source": "./src/index.tsx",
"main": "./lib/commonjs/index.js",
diff --git a/packages/react-native-contentpass/src/Contentpass.test.ts b/packages/react-native-contentpass/src/Contentpass.test.ts
index 2e64adf..68318c3 100644
--- a/packages/react-native-contentpass/src/Contentpass.test.ts
+++ b/packages/react-native-contentpass/src/Contentpass.test.ts
@@ -736,4 +736,18 @@ describe('Contentpass', () => {
expect(sendPageViewEventSpy).not.toHaveBeenCalled();
});
});
+
+ describe('reportError', () => {
+ it('should report the error with its message and tags to Sentry', () => {
+ const error = new Error('Timed out while loading the layer page');
+ const options = {
+ msg: 'Failed to initialize Contentpass layer',
+ tags: { sdkVersion: 'react-native-contentpass-ui@1.2.3' },
+ };
+
+ contentpass.reportError(error, options);
+
+ expect(reportErrorSpy).toHaveBeenCalledWith(error, options);
+ });
+ });
});
diff --git a/packages/react-native-contentpass/src/Contentpass.ts b/packages/react-native-contentpass/src/Contentpass.ts
index 8cf58d9..d68d61c 100644
--- a/packages/react-native-contentpass/src/Contentpass.ts
+++ b/packages/react-native-contentpass/src/Contentpass.ts
@@ -20,7 +20,11 @@ import { RefreshTokenStrategy } from './types/RefreshTokenStrategy';
import fetchContentpassToken from './contentpassTokenUtils/fetchContentpassToken';
import validateSubscription from './contentpassTokenUtils/validateSubscription';
import type { ContentpassConfig } from './types/ContentpassConfig';
-import { initSentry, reportError } from './sentryIntegration';
+import {
+ initSentry,
+ reportError,
+ type ReportErrorOptions,
+} from './sentryIntegration';
import sendStats from './countImpressionUtils/sendStats';
import sendPageViewEvent from './countImpressionUtils/sendPageViewEvent';
import logger, { enableLogger } from './logger';
@@ -42,6 +46,7 @@ interface ContentpassInterface {
logout: () => Promise;
registerObserver: (observer: ContentpassObserver) => void;
recoverFromError: () => Promise;
+ reportError: (error: Error, options?: ReportErrorOptions) => void;
unregisterObserver: (observer: ContentpassObserver) => void;
}
@@ -166,6 +171,12 @@ export default class Contentpass implements ContentpassInterface {
}
};
+ // Lets the Contentpass UI packages report their own failures to the SDK's
+ // Sentry project, which they have no client for.
+ public reportError = (error: Error, options?: ReportErrorOptions) => {
+ reportError(error, options);
+ };
+
public registerObserver(observer: ContentpassObserver) {
logger.info('Registering observer');
if (this.contentpassStateObservers.includes(observer)) {
diff --git a/packages/react-native-contentpass/src/sentryIntegration.test.ts b/packages/react-native-contentpass/src/sentryIntegration.test.ts
index 2f42916..20e0301 100644
--- a/packages/react-native-contentpass/src/sentryIntegration.test.ts
+++ b/packages/react-native-contentpass/src/sentryIntegration.test.ts
@@ -124,6 +124,20 @@ describe('sentryIntegration', () => {
expect(captureExceptionMock).toHaveBeenCalledWith(err);
});
+ it('should attach the given tags to the reported event only', () => {
+ initSentry({ propertyId: 'test-id' });
+ setTagsMock.mockClear();
+ const err = new Error('test');
+ const tags = { sdkVersion: 'react-native-contentpass-ui@1.2.3' };
+
+ reportError(err, { msg: 'test message', tags });
+
+ expect(captureExceptionMock).toHaveBeenCalledWith(err, {
+ captureContext: { tags },
+ });
+ expect(setTagsMock).not.toHaveBeenCalled();
+ });
+
it('should not throw error when reportError is called before initSentry', () => {
const err = new Error('test');
const msg = 'test message';
diff --git a/packages/react-native-contentpass/src/sentryIntegration.ts b/packages/react-native-contentpass/src/sentryIntegration.ts
index 387e69f..d995706 100644
--- a/packages/react-native-contentpass/src/sentryIntegration.ts
+++ b/packages/react-native-contentpass/src/sentryIntegration.ts
@@ -69,11 +69,15 @@ export const initSentry = (args: InitSentryArgs) => {
});
};
-type ReportErrorOptions = {
+export type ReportErrorOptions = {
msg?: string;
+ tags?: Record;
};
-export const reportError = (err: Error, { msg }: ReportErrorOptions = {}) => {
+export const reportError = (
+ err: Error,
+ { msg, tags }: ReportErrorOptions = {}
+) => {
logger.error({ err }, msg || 'Unexpected error');
if (!sentryScope) {
return;
@@ -87,7 +91,11 @@ export const reportError = (err: Error, { msg }: ReportErrorOptions = {}) => {
});
}
- sentryScope.captureException(err);
+ if (tags) {
+ sentryScope.captureException(err, { captureContext: { tags } });
+ } else {
+ sentryScope.captureException(err);
+ }
};
export const __internal_reset_sentry_scope = () => {
diff --git a/yarn.lock b/yarn.lock
index c6f78cf..ef9e723 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3535,7 +3535,7 @@ __metadata:
react-test-renderer: 19.2.0
typescript: ^5.9.3
peerDependencies:
- "@contentpass/react-native-contentpass": "*"
+ "@contentpass/react-native-contentpass": ">=0.10.0"
react: "*"
react-native: "*"
react-native-webview: "*"