diff --git a/examples/consentmanager/CHANGELOG.md b/examples/consentmanager/CHANGELOG.md index 1b5d8dc..2d09506 100644 --- a/examples/consentmanager/CHANGELOG.md +++ b/examples/consentmanager/CHANGELOG.md @@ -1,5 +1,12 @@ # @contentpass/examples-consentmanager +## 0.0.6 + +### Patch Changes + +- Updated dependencies []: + - @contentpass/react-native-contentpass-ui@0.7.0 + ## 0.0.5 ### Patch Changes diff --git a/examples/consentmanager/package.json b/examples/consentmanager/package.json index e787c5d..3c36ddd 100644 --- a/examples/consentmanager/package.json +++ b/examples/consentmanager/package.json @@ -1,6 +1,6 @@ { "name": "@contentpass/examples-consentmanager", - "version": "0.0.5", + "version": "0.0.6", "main": "index.ts", "scripts": { "start": "expo start", @@ -11,6 +11,7 @@ "@contentpass/react-native-contentpass": "workspace:*", "@contentpass/react-native-contentpass-cmp-consentmanager": "workspace:*", "@contentpass/react-native-contentpass-ui": "workspace:*", + "@sentry/react-native": "^8.19.0", "cm-sdk-react-native-v3": "^3.10.0", "expo": "~55.0.26", "expo-status-bar": "~55.0.6", diff --git a/examples/onetrust/package.json b/examples/onetrust/package.json index 47e925c..b7d9d14 100644 --- a/examples/onetrust/package.json +++ b/examples/onetrust/package.json @@ -12,6 +12,7 @@ "@contentpass/react-native-contentpass": "workspace:*", "@contentpass/react-native-contentpass-cmp-onetrust": "workspace:*", "@contentpass/react-native-contentpass-ui": "workspace:*", + "@sentry/react-native": "^8.19.0", "expo": "~55.0.26", "expo-status-bar": "~55.0.6", "react": "19.2.0", diff --git a/examples/sourcepoint-expo/package.json b/examples/sourcepoint-expo/package.json index 3cc1e99..3216484 100644 --- a/examples/sourcepoint-expo/package.json +++ b/examples/sourcepoint-expo/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@contentpass/examples-sourcepoint-shared": "workspace:*", + "@sentry/react-native": "^8.19.0", "@sourcepoint/react-native-cmp": "^1.0.13", "expo": "~55.0.26", "expo-status-bar": "~55.0.6", diff --git a/examples/sourcepoint/package.json b/examples/sourcepoint/package.json index 0e44800..4b85f72 100644 --- a/examples/sourcepoint/package.json +++ b/examples/sourcepoint/package.json @@ -14,6 +14,7 @@ "@contentpass/examples-sourcepoint-shared": "workspace:*", "@contentpass/react-native-contentpass": "workspace:*", "@contentpass/react-native-contentpass-ui": "workspace:*", + "@sentry/react-native": "^8.19.0", "@sourcepoint/react-native-cmp": "^1.0.13", "react": "19.2.0", "react-native": "0.83.6", diff --git a/packages/react-native-contentpass-ui/CHANGELOG.md b/packages/react-native-contentpass-ui/CHANGELOG.md index 7274b93..de2f599 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.0 + +### Minor Changes + +- Fix race in Layer init + ## 0.6.1 ### Patch Changes diff --git a/packages/react-native-contentpass-ui/package.json b/packages/react-native-contentpass-ui/package.json index e2f6989..138c9a5 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.6.1", + "version": "0.7.0", "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 new file mode 100644 index 0000000..288dd1f --- /dev/null +++ b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.test.ts @@ -0,0 +1,135 @@ +import { runInNewContext } from 'node:vm'; +import { EARLY_INJECT_JS } from './ContentpassLayer'; + +jest.mock('react-native-webview', () => ({ + WebView: 'WebView', +})); + +type WindowMock = { + postMessage: (...args: unknown[]) => void; + ReactNativeWebView?: { + postMessage: (message: string) => void; + }; +}; + +type DocumentParentMock = { + appendChild: (child: unknown) => void; +}; + +type DocumentMock = { + head: DocumentParentMock | null; + documentElement: DocumentParentMock | null; + createElement: (tagName: string) => { textContent: string }; + addEventListener: ( + eventName: string, + listener: () => void, + useCapture: boolean + ) => void; +}; + +function executeEarlyInjection({ + window, + document, + setInterval, + clearInterval, +}: { + window: WindowMock; + document: DocumentMock; + setInterval: (callback: () => void, delay: number) => number; + clearInterval: (intervalId: number) => void; +}) { + runInNewContext(EARLY_INJECT_JS, { + window, + document, + setInterval, + clearInterval, + }); +} + +describe('EARLY_INJECT_JS', () => { + it('queues messages until the Android bridge becomes available', () => { + const originalPostMessage = jest.fn(); + const window: WindowMock = { postMessage: originalPostMessage }; + const document: DocumentMock = { + head: null, + 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); + }); + + expect(() => + executeEarlyInjection({ + window, + document, + setInterval, + clearInterval, + }) + ).not.toThrow(); + + const readyMessage = { + protocol: 'contentpass-first-layer', + type: 'REQUEST', + action: 'FIRST_LAYER_READY', + }; + window.postMessage(readyMessage); + + expect(originalPostMessage).toHaveBeenCalledWith(readyMessage); + + const nativePostMessage = jest.fn(); + window.ReactNativeWebView = { postMessage: nativePostMessage }; + intervalCallbacks.get(1)?.(); + + expect(nativePostMessage).toHaveBeenCalledWith( + JSON.stringify(readyMessage) + ); + expect(clearInterval).toHaveBeenCalledWith(1); + }); + + it('waits for the DOM before inserting the first-layer styles', () => { + const window: WindowMock = { + postMessage: jest.fn(), + ReactNativeWebView: { postMessage: jest.fn() }, + }; + const appendChild = jest.fn(); + let onDomContentLoaded: (() => void) | undefined; + const style = { textContent: '' }; + const document: DocumentMock = { + head: null, + documentElement: null, + createElement: jest.fn(() => style), + addEventListener: jest.fn((_eventName, listener) => { + onDomContentLoaded = listener; + }), + }; + + executeEarlyInjection({ + window, + document, + setInterval: jest.fn(() => 1), + clearInterval: jest.fn(), + }); + + expect(document.createElement).not.toHaveBeenCalled(); + expect(document.addEventListener).toHaveBeenCalledWith( + 'DOMContentLoaded', + expect.any(Function), + false + ); + + document.head = { appendChild }; + onDomContentLoaded?.(); + + expect(document.createElement).toHaveBeenCalledWith('style'); + expect(style.textContent).toContain('animation-duration: 0s'); + expect(appendChild).toHaveBeenCalledWith(style); + }); +}); diff --git a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx index be82c95..25f9cb0 100644 --- a/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx +++ b/packages/react-native-contentpass-ui/src/components/ContentpassLayer.tsx @@ -28,26 +28,80 @@ function isSameOrNestedPath(pathname: string, basePathname: string): boolean { ); } -const EARLY_INJECT_JS = ` +export const EARLY_INJECT_JS = ` (function () { - 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; }'; - (document.head || document.documentElement).appendChild(style); - var originalPostMessage = window.postMessage; + var pendingMessages = []; + + function postToReactNative(message) { + var bridge = window.ReactNativeWebView; + + if (!bridge || typeof bridge.postMessage !== 'function') { + return false; + } + + try { + bridge.postMessage(message); + return true; + } catch (error) { + return false; + } + } + window.postMessage = function (data) { try { - window.ReactNativeWebView.postMessage( - typeof data === 'string' ? data : JSON.stringify(data) - ); - } catch (e) {} + var message = + typeof data === 'string' ? data : JSON.stringify(data); + + if ( + typeof message === 'string' && + !postToReactNative(message) + ) { + pendingMessages.push(message); + } + } catch (error) {} + if (originalPostMessage) { originalPostMessage.apply(window, arguments); } }; - true; + var bridgeInterval = setInterval(function () { + while ( + pendingMessages.length > 0 && + postToReactNative(pendingMessages[0]) + ) { + pendingMessages.shift(); + } + + if ( + pendingMessages.length === 0 && + window.ReactNativeWebView && + typeof window.ReactNativeWebView.postMessage === 'function' + ) { + clearInterval(bridgeInterval); + } + }, 10); + + function injectStyle() { + var parent = document.head || document.documentElement; + + if (!parent) { + return; + } + + 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); + } + + if (document.head || document.documentElement) { + injectStyle(); + } else { + document.addEventListener('DOMContentLoaded', injectStyle, false); + } })(); + true; `; const styles = StyleSheet.create({ @@ -330,6 +384,7 @@ export default function ContentpassLayer({ }} onLoadStart={() => { console.debug('WebView load start'); + setReady(false); }} onLoadEnd={() => { console.debug('WebView load end'); diff --git a/yarn.lock b/yarn.lock index 0a210c7..c6f78cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3323,6 +3323,7 @@ __metadata: "@react-native/babel-preset": 0.83.6 "@react-native/metro-config": 0.83.6 "@react-native/typescript-config": 0.83.6 + "@sentry/react-native": ^8.19.0 babel-preset-expo: ^55.0.22 cm-sdk-react-native-v3: ^3.10.0 expo: ~55.0.26 @@ -3352,6 +3353,7 @@ __metadata: "@react-native/babel-preset": 0.83.6 "@react-native/metro-config": 0.83.6 "@react-native/typescript-config": 0.83.6 + "@sentry/react-native": ^8.19.0 babel-preset-expo: ^55.0.22 expo: ~55.0.26 expo-status-bar: ~55.0.6 @@ -3381,6 +3383,7 @@ __metadata: "@react-native/babel-preset": 0.83.6 "@react-native/metro-config": 0.83.6 "@react-native/typescript-config": 0.83.6 + "@sentry/react-native": ^8.19.0 "@sourcepoint/react-native-cmp": ^1.0.13 babel-preset-expo: ^55.0.22 expo: ~55.0.26 @@ -3425,6 +3428,7 @@ __metadata: "@react-native/babel-preset": 0.83.6 "@react-native/metro-config": 0.83.6 "@react-native/typescript-config": 0.83.6 + "@sentry/react-native": ^8.19.0 "@sourcepoint/react-native-cmp": ^1.0.13 patch-package: ^8.0.1 postinstall-postinstall: ^2.1.0