diff --git a/examples/consentmanager/CHANGELOG.md b/examples/consentmanager/CHANGELOG.md index 5863fd9..d803d57 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 []: + - @contentpass/react-native-contentpass-ui@0.7.2 + ## 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..7ee6f11 100644 --- a/packages/react-native-contentpass-ui/CHANGELOG.md +++ b/packages/react-native-contentpass-ui/CHANGELOG.md @@ -1,5 +1,11 @@ # @contentpass/react-native-contentpass-ui +## 0.7.2 + +### Patch Changes + +- Prevent Android first-layer readiness race + ## 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..918642a 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.7.2", "description": "Contentpass React Native UI Components", "source": "./src/index.tsx", "main": "./lib/commonjs/index.js", diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.test.ts b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.test.ts index c4dbca6..e558f60 100644 --- a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.test.ts +++ b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.test.ts @@ -7,9 +7,14 @@ jest.mock('react-native-webview', () => ({ type WindowMock = { postMessage: (...args: unknown[]) => void; + parent?: { + postMessage: (...args: unknown[]) => void; + }; ReactNativeWebView?: { postMessage: (message: string) => void; }; + __cpRnPendingMessages?: string[]; + __cpRnBridgeInterval?: number | null; }; type DocumentParentMock = { @@ -57,6 +62,11 @@ describe('ContentpassLayer', () => { expect(readyAfterLoadStart).toBe(true); expect(layerReadyReducer(readyAfterLoadStart, 'url-changed')).toBe(false); }); + + it('becomes visible when load-end fires without a ready message', () => { + expect(layerReadyReducer(false, 'load-ended')).toBe(true); + expect(layerReadyReducer(true, 'load-started')).toBe(true); + }); }); describe('EARLY_INJECT_JS', () => { @@ -145,4 +155,83 @@ describe('EARLY_INJECT_JS', () => { expect(style.textContent).toContain('animation-duration: 0s'); expect(appendChild).toHaveBeenCalledWith(style); }); + + it('forwards parent.postMessage when parent is not the same window', () => { + const originalWindowPostMessage = jest.fn(); + const originalParentPostMessage = jest.fn(); + const parent = { postMessage: originalParentPostMessage }; + const window: WindowMock = { + postMessage: originalWindowPostMessage, + parent, + }; + const document: DocumentMock = { + head: { appendChild: jest.fn() }, + documentElement: null, + createElement: jest.fn(() => ({ textContent: '' })), + addEventListener: jest.fn(), + }; + const intervalCallbacks = new Map void>(); + const setInterval = jest.fn((callback: () => void) => { + const intervalId = intervalCallbacks.size + 1; + intervalCallbacks.set(intervalId, callback); + return intervalId; + }); + const clearInterval = jest.fn((intervalId: number) => { + intervalCallbacks.delete(intervalId); + }); + + executeEarlyInjection({ + window, + document, + setInterval, + clearInterval, + }); + + const readyMessage = { + protocol: 'contentpass-first-layer', + type: 'REQUEST', + action: 'FIRST_LAYER_READY', + }; + parent.postMessage(readyMessage, '*'); + + expect(originalParentPostMessage).toHaveBeenCalledWith(readyMessage, '*'); + expect(originalWindowPostMessage).not.toHaveBeenCalled(); + + const nativePostMessage = jest.fn(); + window.ReactNativeWebView = { postMessage: nativePostMessage }; + intervalCallbacks.get(1)?.(); + + expect(nativePostMessage).toHaveBeenCalledWith( + JSON.stringify(readyMessage) + ); + expect(clearInterval).toHaveBeenCalledWith(1); + }); + + it('is safe to inject twice without starting a second bridge interval', () => { + const window: WindowMock = { postMessage: jest.fn() }; + const document: DocumentMock = { + head: { appendChild: jest.fn() }, + documentElement: null, + createElement: jest.fn(() => ({ textContent: '' })), + addEventListener: jest.fn(), + }; + const setInterval = jest.fn(() => 7); + const clearInterval = jest.fn(); + + executeEarlyInjection({ + window, + document, + setInterval, + clearInterval, + }); + executeEarlyInjection({ + window, + document, + setInterval, + clearInterval, + }); + + expect(setInterval).toHaveBeenCalledTimes(1); + expect(window.__cpRnBridgeInterval).toBe(7); + }); }); diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx index 95ea8d4..39bd97a 100644 --- a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx +++ b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx @@ -9,12 +9,27 @@ import { import { WebView, type WebViewMessageEvent } from 'react-native-webview'; import type { ContentpassLayerEvents } from './ContentpassLayerEvents'; import buildFirstLayerUrl from './buildFirstLayerUrl'; -import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; const MESSAGE_PROTOCOL = 'contentpass-first-layer'; const POPUP_URL_PROTOCOLS = new Set(['http:', 'https:']); -type LayerReadyAction = 'first-layer-ready' | 'load-started' | 'url-changed'; +let firstLayerMountNonce = 0; + +type LayerReadyAction = + | 'first-layer-ready' + | 'load-started' + | 'load-ended' + | 'url-changed'; + +export const LOAD_END_READY_FALLBACK_MS = 500; export function layerReadyReducer( ready: boolean, @@ -22,6 +37,7 @@ export function layerReadyReducer( ): boolean { switch (action) { case 'first-layer-ready': + case 'load-ended': return true; case 'url-changed': return false; @@ -46,8 +62,11 @@ function isSameOrNestedPath(pathname: string, basePathname: string): boolean { export const EARLY_INJECT_JS = ` (function () { - var originalPostMessage = window.postMessage; - var pendingMessages = []; + var pendingMessages = window.__cpRnPendingMessages; + if (!pendingMessages) { + pendingMessages = []; + window.__cpRnPendingMessages = pendingMessages; + } function postToReactNative(message) { var bridge = window.ReactNativeWebView; @@ -64,42 +83,72 @@ export const EARLY_INJECT_JS = ` } } - window.postMessage = function (data) { - try { - var message = - typeof data === 'string' ? data : JSON.stringify(data); + function wrapPostMessage(target) { + if (!target || typeof target.postMessage !== 'function') { + return; + } - if ( - typeof message === 'string' && - !postToReactNative(message) - ) { - pendingMessages.push(message); + if (target.postMessage.__cpRnWrapped) { + return; + } + + var originalPostMessage = target.postMessage; + var wrapped = function (data) { + try { + var message = + typeof data === 'string' ? data : JSON.stringify(data); + + if ( + typeof message === 'string' && + !postToReactNative(message) + ) { + pendingMessages.push(message); + } + } catch (error) {} + + if (originalPostMessage) { + originalPostMessage.apply(target, arguments); } + }; + wrapped.__cpRnWrapped = true; + + try { + target.postMessage = wrapped; } catch (error) {} + } - if (originalPostMessage) { - originalPostMessage.apply(window, arguments); - } - }; - - var bridgeInterval = setInterval(function () { - while ( - pendingMessages.length > 0 && - postToReactNative(pendingMessages[0]) - ) { - pendingMessages.shift(); + wrapPostMessage(window); + try { + if (window.parent) { + wrapPostMessage(window.parent); } + } catch (error) {} - if ( - pendingMessages.length === 0 && - window.ReactNativeWebView && - typeof window.ReactNativeWebView.postMessage === 'function' - ) { - clearInterval(bridgeInterval); - } - }, 10); + if (!window.__cpRnBridgeInterval) { + window.__cpRnBridgeInterval = setInterval(function () { + while ( + pendingMessages.length > 0 && + postToReactNative(pendingMessages[0]) + ) { + pendingMessages.shift(); + } + + if ( + pendingMessages.length === 0 && + window.ReactNativeWebView && + typeof window.ReactNativeWebView.postMessage === 'function' + ) { + clearInterval(window.__cpRnBridgeInterval); + window.__cpRnBridgeInterval = null; + } + }, 10); + } function injectStyle() { + if (window.__cpRnStyleInjected) { + return; + } + var parent = document.head || document.documentElement; if (!parent) { @@ -109,11 +158,13 @@ export const EARLY_INJECT_JS = ` var style = document.createElement('style'); style.textContent = '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; } main, .backdrop { visibility: visible !important; transform: none !important; }'; parent.appendChild(style); + window.__cpRnStyleInjected = true; } if (document.head || document.documentElement) { injectStyle(); - } else { + } else if (!window.__cpRnStyleListener) { + window.__cpRnStyleListener = true; document.addEventListener('DOMContentLoaded', injectStyle, false); } })(); @@ -189,6 +240,7 @@ export default function ContentpassLayer({ vendorCount: number; locale?: string; }) { + const cacheNonce = useState(() => String(++firstLayerMountNonce))[0]; const firstLayerUrl = useMemo(() => { return buildFirstLayerUrl({ baseUrl, @@ -197,17 +249,43 @@ export default function ContentpassLayer({ purposesList, vendorCount, locale, + cacheNonce, }); - }, [baseUrl, planId, propertyId, purposesList, vendorCount, locale]); + }, [ + baseUrl, + planId, + propertyId, + purposesList, + vendorCount, + locale, + cacheNonce, + ]); const [ready, updateReady] = useReducer(layerReadyReducer, false); const [layerUrl, setLayerUrl] = useState(firstLayerUrl); const [popupUrl, setPopupUrl] = useState(null); + const loadEndFallbackTimer = useRef | null>( + null + ); + + const clearLoadEndFallback = useCallback(() => { + if (loadEndFallbackTimer.current) { + clearTimeout(loadEndFallbackTimer.current); + loadEndFallbackTimer.current = null; + } + }, []); + + useEffect(() => clearLoadEndFallback, [clearLoadEndFallback]); + + const markUrlChanged = useCallback(() => { + clearLoadEndFallback(); + updateReady('url-changed'); + }, [clearLoadEndFallback]); useEffect(() => { setLayerUrl(firstLayerUrl); - updateReady('url-changed'); - }, [firstLayerUrl]); + markUrlChanged(); + }, [firstLayerUrl, markUrlChanged]); const closePopup = useCallback(() => setPopupUrl(null), []); @@ -223,10 +301,37 @@ export default function ContentpassLayer({ [firstLayerUrl] ); - const loadLayerUrl = useCallback((url: URL) => { - updateReady('url-changed'); - setLayerUrl(url.toString()); - }, []); + const loadLayerUrl = useCallback( + (url: URL) => { + markUrlChanged(); + setLayerUrl(url.toString()); + }, + [markUrlChanged] + ); + + const scheduleLoadEndReadyFallback = useCallback( + (loadedUrl: string) => { + if (!loadedUrl) { + return; + } + + try { + const loaded = new URL(loadedUrl, firstLayerUrl); + if (loaded.protocol === 'about:' || !isFirstLayerUrl(loaded)) { + return; + } + } catch { + return; + } + + clearLoadEndFallback(); + loadEndFallbackTimer.current = setTimeout(() => { + loadEndFallbackTimer.current = null; + updateReady('load-ended'); + }, LOAD_END_READY_FALLBACK_MS); + }, + [clearLoadEndFallback, firstLayerUrl, isFirstLayerUrl] + ); const openPopup = useCallback( (url: unknown) => { @@ -278,6 +383,7 @@ export default function ContentpassLayer({ switch (msg.action) { case 'FIRST_LAYER_READY': + clearLoadEndFallback(); updateReady('first-layer-ready'); break; case 'ENABLE_SCROLL_ON_PROPERTY': @@ -342,7 +448,9 @@ export default function ContentpassLayer({ javaScriptEnabled domStorageEnabled automaticallyAdjustContentInsets={false} + cacheEnabled={false} injectedJavaScriptBeforeContentLoaded={EARLY_INJECT_JS} + injectedJavaScript={EARLY_INJECT_JS} setSupportMultipleWindows={false} onMessage={(event) => { handleMessage(event); @@ -402,8 +510,9 @@ export default function ContentpassLayer({ console.debug('WebView load start'); updateReady('load-started'); }} - onLoadEnd={() => { + onLoadEnd={(event) => { console.debug('WebView load end'); + scheduleLoadEndReadyFallback(event.nativeEvent.url); }} onLoadProgress={(event) => { console.debug('WebView progress', event.nativeEvent.progress); diff --git a/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.test.ts b/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.test.ts index 65ca2d4..255834d 100644 --- a/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.test.ts +++ b/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.test.ts @@ -40,6 +40,15 @@ describe('buildFirstLayerUrl', () => { ); }); + it('should append a cache nonce to the SDK version parameter', () => { + const url = buildFirstLayerUrl({ ...defaultParams, cacheNonce: '42' }); + const parsed = new URL(url); + + expect(parsed.searchParams.get('v')).toBe( + `react-native-contentpass-ui@${packageJson.version}.42` + ); + }); + it('should omit the locale parameter when no locale is provided', () => { const url = buildFirstLayerUrl(defaultParams); const parsed = new URL(url); diff --git a/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.ts b/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.ts index 42fe5bd..9c99552 100644 --- a/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.ts +++ b/packages/react-native-contentpass-ui/src/components/buildFirstLayerUrl.ts @@ -11,6 +11,7 @@ export default function buildFirstLayerUrl({ purposesList, vendorCount, locale, + cacheNonce, }: { baseUrl: string; propertyId: string; @@ -18,12 +19,18 @@ export default function buildFirstLayerUrl({ purposesList: string[]; vendorCount: number; locale?: string; + cacheNonce?: string; }): string { // FIXME handle trailing slash in baseUrl const url = new URL(`${baseUrl}/first-layer/`); url.searchParams.set('start', 'true'); url.searchParams.set('theme', THEME); - url.searchParams.set('v', SDK_VERSION); + // Unknown start-query keys 400 the first-layer entrypoint, so the per-mount + // cache nonce has to ride on the already-allowed `v` param. + url.searchParams.set( + 'v', + cacheNonce ? `${SDK_VERSION}.${cacheNonce}` : SDK_VERSION + ); if (locale) { url.searchParams.set('locale', locale); }