From 25b99d9bf0ace8db82f3cbdf2b884ce46fa4b3f7 Mon Sep 17 00:00:00 2001 From: Maximilian Haupt Date: Tue, 20 Oct 2026 10:02:48 +0000 Subject: [PATCH 1/5] feat: let the UI package report errors through the SDK The UI package fails the consent gate open on CMP, SDK and layer failures, but only logs them to the console: it has no Sentry client of its own, so none of those failures reach Contentpass. Expose the SDK's reportError on its instance, and let a report carry tags that apply to that event only, so the UI can say which of its versions failed without changing the scope every other report goes through. --- .changeset/sdk-report-error.md | 5 +++++ .../src/Contentpass.test.ts | 14 ++++++++++++++ .../react-native-contentpass/src/Contentpass.ts | 13 ++++++++++++- .../src/sentryIntegration.test.ts | 14 ++++++++++++++ .../src/sentryIntegration.ts | 14 +++++++++++--- 5 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 .changeset/sdk-report-error.md diff --git a/.changeset/sdk-report-error.md b/.changeset/sdk-report-error.md new file mode 100644 index 0000000..5258089 --- /dev/null +++ b/.changeset/sdk-report-error.md @@ -0,0 +1,5 @@ +--- +'@contentpass/react-native-contentpass': minor +--- + +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. 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 = () => { From 613ad69fefb6e56c72749db803ef48569bff5b75 Mon Sep 17 00:00:00 2001 From: Maximilian Haupt Date: Tue, 20 Oct 2026 10:02:57 +0000 Subject: [PATCH 2/5] fix: give the consent layer's page 30s to load before failing open The page-load stage gave the layer 8s on the assumption that it waits for a small static HTML shell. It waits for much more: the timer starts when the layer mounts, so it covers creating the WebView, and onLoadEnd fires on Android's onPageFinished, once the page and every script, font and image it loads have arrived. A fresh Android install is the slowest case for all of it - the first WebView in the process, nothing cached, the CMP SDK's first download running alongside - and it is also when the layer shows, since there is no consent yet. Those installs failed the gate open before the layer ever appeared; 0.8.1, which had no deadline, kept showing the spinner until the page arrived. Default the stage to UI_OPERATION_TIMEOUT_MS like every other gate timeout. A page that cannot load at all still shows up fast: DNS, TLS and connection failures arrive as onError, which already shows the retry screen. The E2E job's 30s override only existed to work around the 8s default, so it goes. An HTTP error on the page now fails with an Error that names the status, rather than the raw native event. --- .changeset/layer-page-load-timeout.md | 5 ++ .github/workflows/e2e-ios.yml | 9 --- examples/onetrust/e2e/README.md | 4 -- examples/onetrust/src/Config.ts | 12 +--- .../ContentpassConsentGateStartup.ts | 2 +- .../src/components/ContentpassLayer.tsx | 24 ++++--- .../ContentpassLayerLoading.test.tsx | 62 ++++++++++++++++++- 7 files changed, 79 insertions(+), 39 deletions(-) create mode 100644 .changeset/layer-page-load-timeout.md diff --git a/.changeset/layer-page-load-timeout.md b/.changeset/layer-page-load-timeout.md new file mode 100644 index 0000000..6bed7dd --- /dev/null +++ b/.changeset/layer-page-load-timeout.md @@ -0,0 +1,5 @@ +--- +'@contentpass/react-native-contentpass-ui': minor +--- + +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. 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/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/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(); + }); +}); From 6d230b2b843875504b276b28ec882195d865dd19 Mon Sep 17 00:00:00 2001 From: Maximilian Haupt Date: Tue, 20 Oct 2026 10:03:13 +0000 Subject: [PATCH 3/5] fix: tell the host app when the gate fails open, and report why Failing open renders the app in place of the consent layer, but the host app was never told: onVisibilityChange had reported true when the layer appeared and nothing afterwards. An app that hides its content while the layer is visible stayed blank for the rest of the session. Track what the host app was last told and report false when the gate fails open. Once failed open, the visibility policy stops running, so a later consent change cannot report the layer as visible again. Reporting against the last reported value rather than the gate's internal state also stops the repeated true the host got after every second layer or login, during which the gate hides the layer without telling it. Report every fail-open through the SDK's reportError, tagged with the UI package version, except the SDK's own ERROR state, which the SDK reported when it entered it. That needs the SDK's new reportError, so the peer range now starts at 0.10.0. Document the timeouts prop, which the README did not mention. --- .../fail-open-visibility-and-reporting.md | 5 + .../react-native-contentpass-ui/README.md | 30 ++- .../react-native-contentpass-ui/package.json | 2 +- .../ContentpassConsentGate.test.tsx | 171 ++++++++++++++++++ .../src/components/ContentpassConsentGate.tsx | 61 +++++-- .../src/components/buildFirstLayerUrl.ts | 4 +- .../src/components/sdkVersion.ts | 4 + yarn.lock | 2 +- 8 files changed, 261 insertions(+), 18 deletions(-) create mode 100644 .changeset/fail-open-visibility-and-reporting.md create mode 100644 packages/react-native-contentpass-ui/src/components/ContentpassConsentGate.test.tsx create mode 100644 packages/react-native-contentpass-ui/src/components/sdkVersion.ts diff --git a/.changeset/fail-open-visibility-and-reporting.md b/.changeset/fail-open-visibility-and-reporting.md new file mode 100644 index 0000000..669ecb1 --- /dev/null +++ b/.changeset/fail-open-visibility-and-reporting.md @@ -0,0 +1,5 @@ +--- +'@contentpass/react-native-contentpass-ui': minor +--- + +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. 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..5bed04f 100644 --- a/packages/react-native-contentpass-ui/package.json +++ b/packages/react-native-contentpass-ui/package.json @@ -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/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/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: "*" From 34876935699737abc76c8f99747d64bc0171a641 Mon Sep 17 00:00:00 2001 From: Maximilian Haupt Date: Thu, 22 Oct 2026 13:39:27 +0000 Subject: [PATCH 4/5] test: submit the E2E login with the return key The happy-path flow tapped the staging login page's "Log in" button. The button sits in a footer pinned to the bottom of the page, and once the password field has focus the keyboard's toolbar covers the bottom of the viewport. Where the page ends up after the keyboard opens varies by a few points between runs: when the button's centre fell inside the toolbar, the toolbar took the tap, the form was never submitted, and the flow timed out waiting for the app to show the logged-in state. Pressing return in the password field submits the form the same way, without depending on where the button is. --- examples/onetrust/e2e/flows/happy-path.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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: From f1d517c0c738a0f3b432f4c5c33414a6eec2e319 Mon Sep 17 00:00:00 2001 From: Maximilian Haupt Date: Thu, 22 Oct 2026 14:10:26 +0000 Subject: [PATCH 5/5] chore: version packages @contentpass/react-native-contentpass 0.9.1 -> 0.10.0 @contentpass/react-native-contentpass-ui 0.10.1 -> 0.11.0 --- .changeset/fail-open-visibility-and-reporting.md | 5 ----- .changeset/layer-page-load-timeout.md | 5 ----- .changeset/sdk-report-error.md | 5 ----- packages/react-native-contentpass-ui/CHANGELOG.md | 8 ++++++++ packages/react-native-contentpass-ui/package.json | 2 +- packages/react-native-contentpass/CHANGELOG.md | 6 ++++++ packages/react-native-contentpass/package.json | 2 +- 7 files changed, 16 insertions(+), 17 deletions(-) delete mode 100644 .changeset/fail-open-visibility-and-reporting.md delete mode 100644 .changeset/layer-page-load-timeout.md delete mode 100644 .changeset/sdk-report-error.md diff --git a/.changeset/fail-open-visibility-and-reporting.md b/.changeset/fail-open-visibility-and-reporting.md deleted file mode 100644 index 669ecb1..0000000 --- a/.changeset/fail-open-visibility-and-reporting.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@contentpass/react-native-contentpass-ui': minor ---- - -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. diff --git a/.changeset/layer-page-load-timeout.md b/.changeset/layer-page-load-timeout.md deleted file mode 100644 index 6bed7dd..0000000 --- a/.changeset/layer-page-load-timeout.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@contentpass/react-native-contentpass-ui': minor ---- - -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. diff --git a/.changeset/sdk-report-error.md b/.changeset/sdk-report-error.md deleted file mode 100644 index 5258089..0000000 --- a/.changeset/sdk-report-error.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@contentpass/react-native-contentpass': minor ---- - -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. 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/package.json b/packages/react-native-contentpass-ui/package.json index 5bed04f..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", 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",