diff --git a/packages/react-native-babel-plugin/scripts/benchmark-generate-sr-assets.sh b/packages/react-native-babel-plugin/scripts/benchmark-generate-sr-assets.sh new file mode 100755 index 000000000..d23d53b3d --- /dev/null +++ b/packages/react-native-babel-plugin/scripts/benchmark-generate-sr-assets.sh @@ -0,0 +1,355 @@ +#!/bin/bash +# +# Benchmark script for generate-sr-assets.ts +# +# This script tests the performance of the Session Replay asset generation. +# It can optionally create synthetic test files to simulate larger projects. +# +# Usage: +# ./benchmark-generate-sr-assets.sh [OPTIONS] +# +# Options: +# --synthetic N Generate N synthetic test files (default: 0, use existing project) +# --cleanup Remove synthetic files after test +# --runs N Number of runs to average (default: 1) +# --help Show this help message +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLUGIN_DIR="$(dirname "$SCRIPT_DIR")" +REPO_ROOT="$(dirname "$(dirname "$PLUGIN_DIR")")" +EXAMPLE_DIR="$REPO_ROOT/example" +SYNTHETIC_DIR="$EXAMPLE_DIR/src/__synthetic_benchmark__" + +# Default options +SYNTHETIC_COUNT=0 +CLEANUP=false +RUNS=1 + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +print_header() { + echo -e "\n${BLUE}═══════════════════════════════════════════════════════════════${NC}" + echo -e "${BLUE} $1${NC}" + echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}\n" +} + +print_success() { + echo -e "${GREEN}✓ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠ $1${NC}" +} + +print_error() { + echo -e "${RED}✗ $1${NC}" +} + +show_help() { + head -25 "$0" | tail -20 + exit 0 +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --synthetic) + SYNTHETIC_COUNT="$2" + shift 2 + ;; + --cleanup) + CLEANUP=true + shift + ;; + --runs) + RUNS="$2" + shift 2 + ;; + --help) + show_help + ;; + *) + echo "Unknown option: $1" + show_help + ;; + esac +done + +# Generate synthetic test files +generate_synthetic_files() { + local count=$1 + print_header "Generating $count synthetic test files" + + mkdir -p "$SYNTHETIC_DIR" + + # Templates for different file types + # All templates use inline SVG components with static values for clean benchmark output + + local svg_component='import React from "react"; +import Svg, { Path, Circle, Rect, G, Defs, LinearGradient, Stop } from "react-native-svg"; + +export const SyntheticIcon%d = ({ size = 24 }) => ( + + + + + + + + + + + +); + +export default SyntheticIcon%d; +' + + local regular_component='import React from "react"; +import { View, Text, TouchableOpacity, StyleSheet } from "react-native"; + +interface Props { + title: string; + onPress?: () => void; + variant?: "primary" | "secondary"; +} + +export const SyntheticComponent%d: React.FC = ({ + title, + onPress, + variant = "primary" +}) => { + return ( + + + {title} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + padding: 16, + backgroundColor: "#007AFF", + borderRadius: 8, + }, + secondary: { + backgroundColor: "#5856D6", + }, + inner: { + alignItems: "center", + }, + text: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + }, +}); + +export default SyntheticComponent%d; +' + + # Complex SVG component with multiple nested elements (no Text - not supported by transformer) + local svg_complex_component='import React from "react"; +import { View, StyleSheet } from "react-native"; +import Svg, { Path, Circle, G, Polygon, Ellipse, Line, Polyline, Rect } from "react-native-svg"; + +export const SyntheticComplexSvg%d = () => { + return ( + + + + + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: "center", + justifyContent: "center", + }, +}); + +export default SyntheticComplexSvg%d; +' + + local progress=0 + local svg_ratio=30 # 30% of files will have SVG content + + for i in $(seq 1 $count); do + local random=$((RANDOM % 100)) + local filename + local content + + if [ $random -lt $svg_ratio ]; then + # SVG component (30%) + if [ $((random % 2)) -eq 0 ]; then + filename="SvgComponent$i.tsx" + content=$(printf "$svg_component" $i $i $i $i) + else + filename="SvgComplexComponent$i.tsx" + content=$(printf "$svg_complex_component" $i $i) + fi + else + # Regular component (70%) + filename="Component$i.tsx" + content=$(printf "$regular_component" $i $i) + fi + + echo "$content" > "$SYNTHETIC_DIR/$filename" + + # Show progress every 100 files + if [ $((i % 100)) -eq 0 ] || [ $i -eq $count ]; then + printf "\r Generated %d/%d files..." $i $count + fi + done + + echo "" + print_success "Created $count synthetic files in $SYNTHETIC_DIR" +} + +# Clean up synthetic files +cleanup_synthetic_files() { + if [ -d "$SYNTHETIC_DIR" ]; then + print_header "Cleaning up synthetic files" + rm -rf "$SYNTHETIC_DIR" + print_success "Removed $SYNTHETIC_DIR" + fi +} + +# Run the benchmark +# Outputs duration to a temp file so we can display script output AND capture timing +run_benchmark() { + local run_number=$1 + local duration_file="$2" + + echo -e "\n${YELLOW}Run $run_number:${NC}" + + # Clear any existing assets + local assets_dir="$REPO_ROOT/packages/react-native-session-replay/assets" + if [ -d "$assets_dir" ]; then + rm -f "$assets_dir"/*.svg "$assets_dir"/assets.bin "$assets_dir"/assets.json 2>/dev/null || true + fi + + # Run the script and capture timing + local start_time=$(date +%s.%N) + + cd "$EXAMPLE_DIR" + # Run the generate script - output goes directly to terminal + node "$PLUGIN_DIR/lib/commonjs/cli/generate-sr-assets.js" + + local end_time=$(date +%s.%N) + local duration=$(echo "$end_time - $start_time" | bc) + + echo -e "\n ${GREEN}Benchmark duration: ${duration}s${NC}" + + # Write duration to temp file for collection + echo "$duration" >> "$duration_file" +} + +# Main execution +main() { + print_header "Session Replay Asset Generation Benchmark" + + echo "Configuration:" + echo " • Synthetic files: $SYNTHETIC_COUNT" + echo " • Number of runs: $RUNS" + echo " • Cleanup after: $CLEANUP" + echo " • Example directory: $EXAMPLE_DIR" + + # Step 1: Build the plugin + print_header "Step 1: Building the babel plugin" + cd "$PLUGIN_DIR" + yarn prepare + print_success "Plugin built successfully" + + # Step 2: Generate synthetic files if requested + if [ $SYNTHETIC_COUNT -gt 0 ]; then + generate_synthetic_files $SYNTHETIC_COUNT + fi + + # Count total source files + local total_files=$(find "$EXAMPLE_DIR/src" -name "*.tsx" -o -name "*.ts" -o -name "*.js" -o -name "*.jsx" 2>/dev/null | wc -l | tr -d ' ') + echo -e "\n${BLUE}Total source files to process: $total_files${NC}" + + # Step 3: Run benchmark + print_header "Step 3: Running benchmark ($RUNS run(s))" + + local total_duration=0 + local durations=() + local duration_file=$(mktemp) + + for run in $(seq 1 $RUNS); do + run_benchmark $run "$duration_file" + done + + # Read durations from temp file + while IFS= read -r duration; do + durations+=("$duration") + total_duration=$(echo "$total_duration + $duration" | bc) + done < "$duration_file" + rm -f "$duration_file" + + # Calculate statistics + local avg_duration=$(echo "scale=3; $total_duration / $RUNS" | bc) + local files_per_second=$(echo "scale=1; $total_files / $avg_duration" | bc) + + # Step 4: Results + print_header "Results" + + echo "Summary:" + echo " • Files processed: $total_files" + echo " • Number of runs: $RUNS" + echo " • Average duration: ${avg_duration}s" + echo " • Processing rate: ~${files_per_second} files/second" + + if [ $RUNS -gt 1 ]; then + echo "" + echo "Individual runs:" + for i in "${!durations[@]}"; do + echo " • Run $((i+1)): ${durations[$i]}s" + done + fi + + # Estimate scaling + echo "" + echo "Estimated times for larger projects:" + for scale in 1000 2000 5000 10000; do + local estimated=$(echo "scale=1; $scale / $files_per_second" | bc) + echo " • $scale files: ~${estimated}s" + done + + # Step 5: Cleanup if requested + if [ "$CLEANUP" = true ] && [ $SYNTHETIC_COUNT -gt 0 ]; then + cleanup_synthetic_files + fi + + print_header "Benchmark Complete" +} + +# Handle cleanup on exit if interrupted +trap 'if [ "$CLEANUP" = true ]; then cleanup_synthetic_files; fi' EXIT + +# Run main +main + diff --git a/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts b/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts index c94485c6d..b576e527e 100644 --- a/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts +++ b/packages/react-native-babel-plugin/src/cli/generate-sr-assets.ts @@ -8,6 +8,7 @@ import { transformSync } from '@babel/core'; import glob from 'fast-glob'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import babelPlugin from '../index'; @@ -15,6 +16,8 @@ import { clearAssetsDir, getAssetsPath } from '../libraries/react-native-svg/processing/fs'; +import { ReactNativeSVG } from '../libraries/react-native-svg'; +import type { LocalSvgMap } from '../types'; type SvgIndexEntry = { offset: number; @@ -23,6 +26,22 @@ type SvgIndexEntry = { type SvgIndex = Record; +// Patterns that indicate a file might contain SVG usage +const SVG_INDICATORS = [ + /\.svg['"`]/i, // SVG file imports + /]/i, // react-native-svg Svg component + /from\s+['"]react-native-svg['"]/i, // react-native-svg import + /import.*['"].*\.svg['"`]/i // SVG import statement +]; + +/** + * Quick check if a file might contain SVG-related code. + * This is a fast heuristic to skip files that definitely don't have SVGs. + */ +function mightContainSvg(code: string): boolean { + return SVG_INDICATORS.some(pattern => pattern.test(code)); +} + /** * Merges all individual SVG files into assets.bin and creates an index in assets.json. * This function reads all .svg files from the assets directory and packs them into @@ -42,8 +61,7 @@ function mergeSvgAssets(assetsDir: string) { let offset = 0; // Read SVG files from directory - let files: string[] = []; - files = fs + const files = fs .readdirSync(assetsDir) .filter(f => f.endsWith('.svg')) .sort(); @@ -93,6 +111,109 @@ function mergeSvgAssets(assetsDir: string) { } } +/** + * Process a single file through the Babel plugin. + * Returns true if processing was successful, false otherwise. + */ +function processFile( + file: string, + pluginConfig: [any, any], + presets: any[] +): { success: boolean; error?: string } { + try { + const code = fs.readFileSync(file, 'utf8'); + + // Early exit: skip files that don't contain SVG-related code + if (!mightContainSvg(code)) { + return { success: true }; + } + + // Transform the file using the Babel plugin with SVG tracking enabled + transformSync(code, { + filename: file, + plugins: [pluginConfig], // Reuse the same plugin config + presets, + // Don't generate actual output, we just want the asset generation + code: false, + ast: false + }); + + return { success: true }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { success: false, error: errorMessage }; + } +} + +/** + * Process files in batches to balance memory usage and parallelism. + * Uses Promise.all for concurrent processing within each batch. + */ +async function processFilesInBatches( + files: string[], + pluginConfig: [any, any], + presets: any[], + batchSize: number, + onProgress?: (processed: number, total: number) => void +): Promise<{ errorCount: number }> { + let errorCount = 0; + let processed = 0; + + // Process files in batches + for (let i = 0; i < files.length; i += batchSize) { + const batch = files.slice(i, i + batchSize); + + // Process batch concurrently using Promise.all + // eslint-disable-next-line no-await-in-loop -- Intentional: process batches sequentially for memory control and progress reporting + const results = await Promise.all( + batch.map(async file => { + // Use setImmediate to allow event loop to process other tasks + await new Promise(resolve => setImmediate(resolve)); + return { + file, + result: processFile(file, pluginConfig, presets) + }; + }) + ); + + // Count errors + for (const { result } of results) { + if (!result.success) { + errorCount++; + } + } + + processed += batch.length; + onProgress?.(processed, files.length); + } + + return { errorCount }; +} + +/** + * Display a progress bar in the terminal. + */ +function showProgress(current: number, total: number, startTime: number): void { + const percent = Math.round((current / total) * 100); + const elapsed = (Date.now() - startTime) / 1000; + const rate = current / elapsed; + const eta = rate > 0 ? Math.round((total - current) / rate) : 0; + + const barWidth = 30; + const filled = Math.round((current / total) * barWidth); + const empty = barWidth - filled; + const bar = '█'.repeat(filled) + '░'.repeat(empty); + + process.stdout.write( + `\r[${bar}] ${percent}% (${current}/${total}) - ETA: ${eta}s` + ); + + if (current === total) { + process.stdout.write('\n'); + } +} + /** * CLI tool to pre-generate SVG assets for Session Replay. * @@ -103,12 +224,19 @@ function mergeSvgAssets(assetsDir: string) { * This should be ran before `pod install` on iOS to ensure that native asset * references are available during the build process. * + * Optimizations applied: + * - SVG map is built once before processing (not per-file) + * - Plugin config is created once and reused (enables Babel cache hits) + * - Files are filtered early if they don't contain SVG patterns + * - Batch processing for better throughput + * - Progress reporting for visibility + * * Usage: * npx @datadog/mobile-react-native-babel-plugin generate-sr-assets * or * npx datadog-generate-sr-assets */ -function generateSessionReplayAssets() { +async function generateSessionReplayAssets() { const rootDir = process.cwd(); const assetsPath = getAssetsPath(); @@ -116,11 +244,25 @@ function generateSessionReplayAssets() { process.exit(0); } - console.info(`Scanning for session replay assets in ${rootDir}...`); + const totalStartTime = Date.now(); + + console.info(`\n🔍 Scanning for session replay assets in ${rootDir}...\n`); // Clear existing assets to ensure a fresh state clearAssetsDir(assetsPath); + // Step 1: Build SVG map ONCE (this was the O(N²) bottleneck) + console.info('📦 Building SVG import map...'); + const svgMapStartTime = Date.now(); + const prebuiltSvgMap: LocalSvgMap = ReactNativeSVG.buildSvgMapFromDirectory( + rootDir + ); + const svgMapTime = ((Date.now() - svgMapStartTime) / 1000).toFixed(2); + const svgCount = Object.keys(prebuiltSvgMap).length; + console.info(` Found ${svgCount} SVG imports in ${svgMapTime}s\n`); + + // Step 2: Scan for source files + console.info('📂 Scanning source files...'); const files = glob.sync(['**/*.{js,jsx,ts,tsx}'], { cwd: rootDir, absolute: true, @@ -138,61 +280,73 @@ function generateSessionReplayAssets() { ] }); - let errorCount = 0; - const errors: Array<{ file: string; error: string }> = []; + console.info(` Found ${files.length} source files\n`); - for (const file of files) { - try { - const code = fs.readFileSync(file, 'utf8'); - - // Transform the file using the Babel plugin with SVG tracking enabled - transformSync(code, { - filename: file, - plugins: [ - [ - babelPlugin, - { - sessionReplay: { - svgTracking: true - }, - __internal_saveSvgMapToDisk: true - } - ] - ], - presets: [ - [ - '@babel/preset-typescript', - { isTSX: true, allExtensions: true } - ], - '@babel/preset-react' - ], - // Don't generate actual output, we just want the asset generation - code: false, - ast: false - }); - } catch (error) { - errorCount++; - const errorMessage = - error instanceof Error ? error.message : String(error); - errors.push({ file, error: errorMessage }); - } + if (files.length === 0) { + console.info('No source files found to process.'); + return; } + // Step 3: Create plugin config once (reused across all files for cache hits) + const pluginConfig: [any, any] = [ + babelPlugin, + { + sessionReplay: { + svgTracking: true + }, + __internal_saveSvgMapToDisk: true, + __internal_prebuiltSvgMap: prebuiltSvgMap + } + ]; + + const presets = [ + ['@babel/preset-typescript', { isTSX: true, allExtensions: true }], + '@babel/preset-react' + ]; + + // Step 4: Process files in batches + // Use batch size based on available CPUs + const cpuCount = os.cpus().length; + const batchSize = Math.max(cpuCount * 2, 16); + + console.info(`⚙️ Processing files (batch size: ${batchSize})...`); + const processStartTime = Date.now(); + + const { errorCount } = await processFilesInBatches( + files, + pluginConfig, + presets, + batchSize, + (processed, total) => showProgress(processed, total, processStartTime) + ); + + const processTime = ((Date.now() - processStartTime) / 1000).toFixed(2); + console.info(` Processed ${files.length} files in ${processTime}s\n`); + if (errorCount > 0) { - console.warn(`${errorCount} files had errors`); + console.warn(`⚠️ ${errorCount} files had errors`); } - // Merge all individual SVG files into assets.bin and assets.json + // Step 5: Merge all individual SVG files into assets.bin and assets.json + console.info('📦 Merging SVG assets...'); mergeSvgAssets(assetsPath); + const totalTime = ((Date.now() - totalStartTime) / 1000).toFixed(2); + console.info(`\n✅ Asset generation completed in ${totalTime}s`); + if (errorCount > 0) { console.info( - 'Asset generation finished, but some files encountered errors.' + ' Some files encountered errors (this is usually fine for non-React files).' ); } - console.info('Your assets are now ready to be used by Session Replay.'); + console.info( + ' Your assets are now ready to be used by Session Replay.\n' + ); } // TODO: Add flag support [e.g., --verbose] (RUM-12186) -generateSessionReplayAssets(); +generateSessionReplayAssets().catch(err => { + console.error('Fatal error during asset generation:', err); + process.exit(1); +}); diff --git a/packages/react-native-babel-plugin/src/index.ts b/packages/react-native-babel-plugin/src/index.ts index 8379c600f..b7576803a 100644 --- a/packages/react-native-babel-plugin/src/index.ts +++ b/packages/react-native-babel-plugin/src/index.ts @@ -58,7 +58,8 @@ export default declare( api.types, process.cwd(), assetsPath, - options.__internal_saveSvgMapToDisk || false + options.__internal_saveSvgMapToDisk || false, + options.__internal_prebuiltSvgMap ); } }, diff --git a/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts index 1ef61ff28..29b72fd33 100644 --- a/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts +++ b/packages/react-native-babel-plugin/src/libraries/react-native-svg/index.ts @@ -15,11 +15,14 @@ import pathN from 'path'; import { optimize } from 'svgo'; import { v4 as uuidv4 } from 'uuid'; +import type { LocalSvgMap } from '../../types'; import { getNodeName } from '../../utils'; import { HandlerResolver } from './handlers/HandlerResolver'; import { writeAssetToDisk } from './processing/fs'; +export type { LocalSvgMap, LocalSvgMapEntry } from '../../types'; + type SvgOffset = { start: number; length: number; @@ -40,15 +43,126 @@ export class ReactNativeSVG { svgOffset: Record = {}; - localSvgMap: Record = {}; + localSvgMap: LocalSvgMap = {}; constructor( private t: typeof Babel.types, private rootDir: string, private assetsPath: string, - private saveSvgMapToDisk: boolean = false + private saveSvgMapToDisk: boolean = false, + prebuiltSvgMap?: LocalSvgMap ) { - this.buildSvgMap(); + if (prebuiltSvgMap) { + this.localSvgMap = prebuiltSvgMap; + } else { + this.buildSvgMap(); + } + } + + /** + * Scans all source files in a directory to detect `.svg` imports and builds a mapping + * of JSX identifiers to their corresponding SVG file paths. + * + * This static method can be called independently to pre-build the SVG map, + * which can then be passed to the constructor for better performance. + * + * @param rootDir - The root directory to scan for SVG imports + * @param babelTypes - Optional Babel types helper (uses a simple name extractor if not provided) + * @returns A mapping of SVG identifiers to their file paths + */ + static buildSvgMapFromDirectory( + rootDir: string, + babelTypes?: typeof Babel.types + ): LocalSvgMap { + const localSvgMap: LocalSvgMap = {}; + + const files = glob.sync(['**/*.{js,jsx,ts,tsx}'], { + cwd: rootDir, + absolute: true, + ignore: [ + '**/node_modules/**', + '**/lib/**', + '**/dist/**', + '**/build/**', + '**/*.d.ts', + '**/*.test.*', + '**/*.spec.*', + '**/*.config.js', + '**/__tests__/**', + '**/__mocks__/**' + ] + }); + + for (const file of files) { + try { + const code = fs.readFileSync(file, 'utf8'); + if (!code) { + continue; + } + + const ast = parser.parse(code, { + sourceType: 'module', + plugins: [ + 'jsx', + 'typescript', + 'exportDefaultFrom', + 'classProperties', + 'dynamicImport' + ] + }); + + traverse(ast, { + ImportDeclaration: path => { + const source = path.node.source.value; + if (!source.endsWith('.svg')) { + return; + } + + const resolved = pathN.resolve( + pathN.dirname(file), + source + ); + for (const spec of path.node.specifiers) { + const name = babelTypes + ? getNodeName(babelTypes, spec.local.name) + : spec.local.name; + if (name) { + localSvgMap[name] = { + path: resolved + }; + } + } + }, + ExportNamedDeclaration: path => { + const source = path.node.source?.value; + if (!source?.endsWith('.svg')) { + return; + } + + const resolved = pathN.resolve( + pathN.dirname(file), + source + ); + for (const spec of path.node.specifiers) { + if (spec.type === 'ExportSpecifier') { + const name = babelTypes + ? getNodeName(babelTypes, spec.local.name) + : spec.local.name; + if (name) { + localSvgMap[name] = { + path: resolved + }; + } + } + } + } + }); + } catch (err) { + // Silently continue on parse errors for static map building + } + } + + return localSvgMap; } /** @@ -90,21 +204,22 @@ export class ReactNativeSVG { } // TODO: Support aliased paths (RUM-12185) - const files = glob.sync( - ['**/*.{js,jsx,ts,tsx}', '**/*.{js,jsx,ts,tsx}'], - { - cwd: this.rootDir, - absolute: true, - ignore: [ - '**/node_modules/**', - '**/lib/**', - '**/dist/**', - '**/*.d.ts', - '**/*.test.*', - '**/*.config.js' - ] - } - ); + const files = glob.sync(['**/*.{js,jsx,ts,tsx}'], { + cwd: this.rootDir, + absolute: true, + ignore: [ + '**/node_modules/**', + '**/lib/**', + '**/dist/**', + '**/build/**', + '**/*.d.ts', + '**/*.test.*', + '**/*.spec.*', + '**/*.config.js', + '**/__tests__/**', + '**/__mocks__/**' + ] + }); for (const file of files) { try { diff --git a/packages/react-native-babel-plugin/src/types/general.ts b/packages/react-native-babel-plugin/src/types/general.ts index 0d17ff8c2..59f860f16 100644 --- a/packages/react-native-babel-plugin/src/types/general.ts +++ b/packages/react-native-babel-plugin/src/types/general.ts @@ -34,6 +34,13 @@ export type SessionReplayOptions = { svgTracking: boolean; }; +export type LocalSvgMapEntry = { + path: string; + content?: string; +}; + +export type LocalSvgMap = Record; + export type PluginOptions = { actionNameAttribute?: string; sessionReplay: SessionReplayOptions; @@ -44,6 +51,8 @@ export type PluginOptions = { }; // Internal option used by CLI - not meant for end users __internal_saveSvgMapToDisk?: boolean; + // Pre-built SVG map to avoid scanning on every file (critical for performance) + __internal_prebuiltSvgMap?: LocalSvgMap; }; export type PluginPassState = Babel.PluginPass & {