diff --git a/packages/react-native-webview/src/__tests__/WebviewDatadogNewArchBlankScreen.test.tsx b/packages/react-native-webview/src/__tests__/WebviewDatadogNewArchBlankScreen.test.tsx new file mode 100644 index 000000000..9d696b1a9 --- /dev/null +++ b/packages/react-native-webview/src/__tests__/WebviewDatadogNewArchBlankScreen.test.tsx @@ -0,0 +1,341 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * Reproduction tests for RUMS-5458: + * White Blank Screen Across All WebViews with RUM Errors + * + * The customer uses a bare React Native app with New Architecture (Fabric/TurboModules) + * and the @datadog/mobile-react-native-webview wrapper. Despite having both the native + * Datadog WebView wrapper and Browser SDK instrumented, WebViews render as blank screens + * with error source labeled "WEBVIEW" in Session Replay. + * + * These tests verify the WebView wrapper behavior in scenarios matching the customer's + * setup, particularly around message handling, allowedHosts propagation, and the + * interaction between the Datadog bridge and user WebView content. + */ + +import { render } from '@testing-library/react-native'; +import { WebView as RNWebView } from 'react-native-webview'; +import React from 'react'; + +import { NativeDdSdk } from '../ext-specs/NativeDdSdk'; +import { WebView } from '../index'; + +jest.mock('react-native-webview', () => { + return { + WebView: jest.fn() + }; +}); + +jest.mock('../specs/NativeDdWebView', () => { + return { + NativeDdWebView: jest.fn() + }; +}); + +jest.mock('../ext-specs/NativeDdSdk', () => { + return { + NativeDdSdk: { + consumeWebviewEvent: jest.fn().mockResolvedValue(undefined), + telemetryError: jest.fn().mockResolvedValue(undefined) + } + }; +}); + +jest.mock('../ext-specs/NativeDdLogs', () => { + return { + __esModule: true, + default: { + error: jest.fn().mockResolvedValue(undefined) + } + }; +}); + +describe('RUMS-5458: WebView blank screen with New Architecture', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Message handling should not interfere with WebView content', () => { + it('should forward non-Datadog messages to user onMessage handler even when message is valid JSON', () => { + /** + * In the customer's setup, the Epic MyChart WebView sends its own + * postMessage calls. These must be forwarded to the user's onMessage + * handler and not swallowed by the Datadog message interceptor. + */ + const userOnMessage = jest.fn(); + const allowedHosts = ['epicmychart.example.com']; + + render( + + ); + + const mockedWebView = jest.mocked(RNWebView); + const datadogOnMessage = mockedWebView.mock.calls[0][0].onMessage; + + // Simulate a WebView postMessage with arbitrary JSON (not Datadog) + const userJsonMessage = JSON.stringify({ + type: 'navigation', + url: 'https://epicmychart.example.com/dashboard' + }); + + datadogOnMessage?.({ + nativeEvent: { data: userJsonMessage } + } as any); + + expect(userOnMessage).toHaveBeenCalledTimes(1); + }); + + it('should not swallow messages when WebView sends empty or null data', () => { + /** + * Blank screen scenario: when WebView fails to load properly, + * it may send null or empty messages. The handler should not throw. + */ + const userOnMessage = jest.fn(); + const allowedHosts = ['epicmychart.example.com']; + + render( + + ); + + const mockedWebView = jest.mocked(RNWebView); + const datadogOnMessage = mockedWebView.mock.calls[0][0].onMessage; + + // Null data should be handled gracefully + expect(() => { + datadogOnMessage?.({ + nativeEvent: { data: null } + } as any); + }).not.toThrow(); + + // Empty string should be forwarded to user handler + datadogOnMessage?.({ + nativeEvent: { data: '' } + } as any); + + // User handler should be called for non-null, non-Datadog messages + expect(userOnMessage).toHaveBeenCalledTimes(1); + }); + + it('should correctly route Datadog NATIVE_EVENT messages to consumeWebviewEvent', () => { + /** + * When the Browser SDK in the WebView sends RUM events through the + * bridge, they should reach consumeWebviewEvent on the native side. + * If this fails, WebView tracking data is lost and Session Replay + * shows the "WEBVIEW" error source. + */ + const userOnMessage = jest.fn(); + const allowedHosts = ['epicmychart.example.com']; + + render( + + ); + + const mockedWebView = jest.mocked(RNWebView); + const datadogOnMessage = mockedWebView.mock.calls[0][0].onMessage; + + const ddEvent = JSON.stringify({ + source: 'DATADOG', + type: 'NATIVE_EVENT', + message: '{"eventType":"view","session":{"id":"abc123"}}' + }); + + datadogOnMessage?.({ + nativeEvent: { data: ddEvent } + } as any); + + expect(NativeDdSdk?.consumeWebviewEvent).toHaveBeenCalledWith( + '{"eventType":"view","session":{"id":"abc123"}}' + ); + expect(userOnMessage).not.toHaveBeenCalled(); + }); + }); + + describe('allowedHosts propagation in New Architecture', () => { + it('should include allowedHosts comment in injectedJavaScriptBeforeContentLoaded even without user JS code', () => { + /** + * In New Architecture, the native side extracts allowedHosts from + * the injectedJavaScriptBeforeContentLoaded prop via a regex comment. + * If allowedHosts is provided but no user JS code is given, the + * comment MUST still be injected for native-side WebView tracking to + * be configured. + * + * This is critical because without the comment, configureWebViewTracking + * is never called on the native side under New Architecture, and the + * WebView is tracked by the Datadog native component but without the + * bridge being set up — potentially causing blank screens. + */ + const allowedHosts = ['epicmychart.example.com', 'www.epic.com']; + + render(); + + const mockedWebView = jest.mocked(RNWebView); + const injectedJs = + mockedWebView.mock.calls[0][0] + .injectedJavaScriptBeforeContentLoaded; + + // The injected JS must contain the allowedHosts comment for native extraction + expect(injectedJs).toBeDefined(); + expect(injectedJs).not.toBeNull(); + expect(injectedJs).toContain('#allowedHosts='); + expect(injectedJs).toContain('epicmychart.example.com'); + expect(injectedJs).toContain('www.epic.com'); + }); + + it('should propagate allowedHosts through nativeConfig props for Old Architecture fallback', () => { + /** + * In Old Architecture, allowedHosts is passed as a native prop via + * nativeConfig. Verify it's always set. + */ + const allowedHosts = ['epicmychart.example.com']; + + render(); + + const mockedWebView = jest.mocked(RNWebView); + const nativeProps = mockedWebView.mock.calls[0][0].nativeConfig + ?.props as any; + + expect(nativeProps?.allowedHosts).toEqual(allowedHosts); + }); + + it('should handle empty allowedHosts gracefully without causing blank screen', () => { + /** + * If the customer initially forgot to set allowedHosts (as described + * in the ticket), the WebView should still render content — it just + * won't have Datadog tracking. The Datadog wrapper must not interfere + * with normal WebView rendering when allowedHosts is empty. + */ + const allowedHosts: string[] = []; + + render(); + + const mockedWebView = jest.mocked(RNWebView); + + // The WebView should still be rendered + expect(mockedWebView).toHaveBeenCalled(); + + // injectedJavaScriptBeforeContentLoaded should either be undefined + // or contain valid (non-interfering) JS — not broken JS that causes + // the WebView to fail + const injectedJs = + mockedWebView.mock.calls[0][0] + .injectedJavaScriptBeforeContentLoaded; + + if (injectedJs !== undefined && injectedJs !== null) { + // If any JS is injected, it should not contain broken allowedHosts + // that would cause the native side to misconfigure tracking + expect(injectedJs).not.toContain('#allowedHosts=[]'); + } + }); + + it('should not inject broken allowedHosts comment when allowedHosts is undefined', () => { + /** + * When allowedHosts is not provided at all, no tracking comment + * should be injected. The WebView should work as a plain WebView. + */ + render(); + + const mockedWebView = jest.mocked(RNWebView); + const injectedJs = + mockedWebView.mock.calls[0][0] + .injectedJavaScriptBeforeContentLoaded; + + // Should be undefined — no Datadog injection when no hosts configured + expect(injectedJs).toBeUndefined(); + }); + }); + + describe('WebView rendering with Datadog wrapper should not cause blank screen', () => { + it('should not override user-provided injectedJavaScriptBeforeContentLoaded', () => { + /** + * The customer may have their own JS injected before content loads. + * The Datadog wrapper must preserve it while adding its own tracking + * comment. If the user's JS is overwritten, it could cause blank screens. + */ + const userJs = "window.__APP_CONFIG__ = { theme: 'dark' };"; + const allowedHosts = ['epicmychart.example.com']; + + render( + + ); + + const mockedWebView = jest.mocked(RNWebView); + const injectedJs = + mockedWebView.mock.calls[0][0] + .injectedJavaScriptBeforeContentLoaded; + + // Must contain both the allowedHosts comment AND the user's JS code + expect(injectedJs).toBeDefined(); + expect(injectedJs).toContain('#allowedHosts='); + expect(injectedJs).toContain('__APP_CONFIG__'); + }); + + it('should preserve user injectedJavaScript when Datadog wrapper is used', () => { + /** + * User-provided injectedJavaScript must still be passed through + * to the underlying WebView. + */ + const userJs = "document.body.style.backgroundColor = 'white';"; + const allowedHosts = ['epicmychart.example.com']; + + render( + + ); + + const mockedWebView = jest.mocked(RNWebView); + const injectedJs = + mockedWebView.mock.calls[0][0].injectedJavaScript; + + expect(injectedJs).toBeDefined(); + expect(injectedJs).toContain('backgroundColor'); + }); + + it('should pass all standard WebView props through to the underlying RNWebView', () => { + /** + * The Datadog wrapper must be transparent for standard WebView props. + * If props like source, style, or scrollEnabled are dropped, the + * WebView may not render content (blank screen). + */ + const source = { uri: 'https://epicmychart.example.com' }; + const style = { flex: 1 }; + const allowedHosts = ['epicmychart.example.com']; + + render( + + ); + + const mockedWebView = jest.mocked(RNWebView); + const passedProps = mockedWebView.mock.calls[0][0]; + + expect(passedProps.source).toEqual(source); + expect(passedProps.style).toEqual(style); + expect(passedProps.scrollEnabled).toBe(true); + expect(passedProps.javaScriptEnabled).toBe(true); + }); + }); +}); diff --git a/packages/react-native-webview/src/__tests__/WebviewDatadogNewArchIntegration.test.ts b/packages/react-native-webview/src/__tests__/WebviewDatadogNewArchIntegration.test.ts new file mode 100644 index 000000000..fd194a71e --- /dev/null +++ b/packages/react-native-webview/src/__tests__/WebviewDatadogNewArchIntegration.test.ts @@ -0,0 +1,219 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +/** + * Integration tests for RUMS-5458: + * Verifies the interaction between JS-side allowedHosts encoding and the + * native-side extraction logic used in New Architecture. + * + * In the New Architecture, the native ViewManager cannot receive custom props + * directly from React. Instead, allowedHosts is encoded as a comment in + * injectedJavaScriptBeforeContentLoaded and extracted on the native side + * via regex. If this encoding/extraction chain breaks, WebView tracking is + * not configured, leading to blank screens and "WEBVIEW" error sources. + */ + +import { wrapJsCodeWithAllowedHosts } from '../utils/webview-js-utils'; + +/** + * Simulates the native-side regex extraction of allowedHosts from + * injectedJavaScriptBeforeContentLoaded. This mirrors the Kotlin code in + * DdSdkReactNativeWebViewManager.extractAllowedHosts (newarch variant). + */ +function extractAllowedHostsFromInjectedJs( + input: string | undefined +): string[] | null { + if (!input) { + return null; + } + + // Matches the Kotlin regex: """//\s*#allowedHosts\s*=\s*(.+)""" + const regex = /\/\/\s*#allowedHosts\s*=\s*(.+)/; + const match = input.match(regex); + if (!match) { + return null; + } + + const jsonString = match[1].trim(); + try { + return JSON.parse(jsonString); + } catch { + return null; + } +} + +describe('RUMS-5458: New Architecture allowedHosts encoding/extraction chain', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('allowedHosts round-trip: JS encoding -> native extraction', () => { + it('should encode and extract single host correctly', () => { + const allowedHosts = ['epicmychart.example.com']; + const injectedJs = wrapJsCodeWithAllowedHosts( + undefined, + allowedHosts + ); + + const extracted = extractAllowedHostsFromInjectedJs(injectedJs); + expect(extracted).toEqual(allowedHosts); + }); + + it('should encode and extract multiple hosts correctly', () => { + const allowedHosts = [ + 'epicmychart.example.com', + 'www.epic.com', + 'portal.optum.com' + ]; + const injectedJs = wrapJsCodeWithAllowedHosts( + undefined, + allowedHosts + ); + + const extracted = extractAllowedHostsFromInjectedJs(injectedJs); + expect(extracted).toEqual(allowedHosts); + }); + + it('should encode and extract hosts with user JS code present', () => { + const allowedHosts = ['epicmychart.example.com']; + const userJs = 'window.__init__ = true;'; + const injectedJs = wrapJsCodeWithAllowedHosts(userJs, allowedHosts); + + const extracted = extractAllowedHostsFromInjectedJs(injectedJs); + expect(extracted).toEqual(allowedHosts); + + // User JS should also be present + expect(injectedJs).toContain('__init__'); + }); + + it('should handle hosts with special characters in URLs', () => { + const allowedHosts = [ + 'my-app.example.com', + 'app.sub.domain.co.uk', + 'localhost:3000' + ]; + const injectedJs = wrapJsCodeWithAllowedHosts( + undefined, + allowedHosts + ); + + const extracted = extractAllowedHostsFromInjectedJs(injectedJs); + expect(extracted).toEqual(allowedHosts); + }); + }); + + describe('Edge cases that may cause blank screens', () => { + it('should return null extraction when allowedHosts is empty array', () => { + /** + * BUG REPRODUCTION: When allowedHosts is an empty array [], + * the JS side encodes it as `// #allowedHosts=[]`, but the native + * side should NOT call configureWebViewTracking with an empty list, + * as this could interfere with WebView rendering. + * + * Expected: empty array should NOT produce an allowedHosts comment, + * because tracking with 0 hosts is meaningless and the native + * component override (DdReactNativeWebView) may still interfere + * with normal rendering. + */ + const allowedHosts: string[] = []; + const injectedJs = wrapJsCodeWithAllowedHosts( + undefined, + allowedHosts + ); + + if (injectedJs !== undefined) { + const extracted = extractAllowedHostsFromInjectedJs(injectedJs); + // If extraction succeeds, it should NOT return an empty array + // because enabling WebView tracking with empty hosts is wrong + expect(extracted).toBeNull(); + } + }); + + it('should not produce allowedHosts comment when allowedHosts is undefined', () => { + /** + * When allowedHosts is undefined, no tracking comment should exist. + * The native side should see no comment and skip tracking setup. + */ + const injectedJs = wrapJsCodeWithAllowedHosts(undefined, undefined); + + expect(injectedJs).toBeUndefined(); + + const extracted = extractAllowedHostsFromInjectedJs(injectedJs); + expect(extracted).toBeNull(); + }); + + it('should produce valid JS code that does not break WebView rendering', () => { + /** + * The injected JS must be syntactically valid. If it contains syntax + * errors, the WebView's JavaScript context will fail, potentially + * causing a blank screen. + */ + const allowedHosts = ['epicmychart.example.com']; + const userJs = "document.addEventListener('load', function() {});"; + const injectedJs = wrapJsCodeWithAllowedHosts(userJs, allowedHosts); + + expect(injectedJs).toBeDefined(); + + // Verify the JS is syntactically valid by attempting to parse it + // eslint-disable-next-line no-eval + expect(() => eval(`(function() { ${injectedJs} })`)).not.toThrow(); + }); + + it('should produce valid JS even with complex user code', () => { + /** + * Complex user-injected JS should be safely wrapped without + * breaking the overall JS syntax. + */ + const allowedHosts = ['epicmychart.example.com']; + const complexUserJs = ` + (function() { + var config = { api: "https://api.example.com" }; + window.postMessage(JSON.stringify(config), "*"); + })(); + `; + const injectedJs = wrapJsCodeWithAllowedHosts( + complexUserJs, + allowedHosts + ); + + expect(injectedJs).toBeDefined(); + // eslint-disable-next-line no-eval + expect(() => eval(`(function() { ${injectedJs} })`)).not.toThrow(); + }); + }); + + describe('DatadogEventBridge injection behavior', () => { + it('should not inject DatadogEventBridge setup code in injectedJavaScriptBeforeContentLoaded', () => { + /** + * BUG INVESTIGATION: The RFC for WebView tracking on React Native + * states that the JS-side should inject a DatadogEventBridge object + * before the WebView loads. However, in the current implementation, + * this bridge is set up on the native side via WebViewTracking.enable(). + * + * If the native side fails to call WebViewTracking.enable() (e.g., + * because allowedHosts extraction failed), the Browser SDK in the + * WebView will not find the bridge and may behave unexpectedly. + * + * Expected: The injected JS should NOT contain DatadogEventBridge + * setup -- that's the native side's job. But we verify the comment + * is present so the native side can do its job. + */ + const allowedHosts = ['epicmychart.example.com']; + const injectedJs = wrapJsCodeWithAllowedHosts( + undefined, + allowedHosts + ); + + expect(injectedJs).toBeDefined(); + // The JS-side should NOT inject DatadogEventBridge + // (that's done natively by WebViewTracking.enable) + expect(injectedJs).not.toContain('DatadogEventBridge'); + + // But it MUST contain the allowedHosts for native extraction + expect(injectedJs).toContain('#allowedHosts='); + }); + }); +});