UNPKG

react-native-debug-toolkit

Version:

A simple yet powerful debugging toolkit for React Native with a convenient floating UI for development

515 lines (476 loc) 19.2 kB
// lib/views/SubViewPerformance.js import React, { useState, useEffect } from 'react'; import { View, Text, StyleSheet, Clipboard, Dimensions, ScrollView, Pressable, Platform } from 'react-native'; import performance, { PerformanceObserver, setResourceLoggingEnabled } from 'react-native-performance'; import JSONTree from 'react-native-json-tree'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); // Make sure resource logging is enabled for this view to capture network data // It might be enabled in the feature setup, but ensure it here too if needed // setResourceLoggingEnabled(true); const theme = { scheme: 'monokai', author: 'wimer hazenberg (http://www.monokai.nl)', base00: '#272822', // Background for JSONTree base01: '#383830', base02: '#49483e', base03: '#75715e', base04: '#a59f85', base05: '#f8f8f2', // Default text color base06: '#f5f4f1', base07: '#f9f8f5', base08: '#f92672', // Red base09: '#fd971f', // Orange base0A: '#f4bf75', // Yellow base0B: '#a6e22e', // Green base0C: '#a1efe4', // Cyan base0D: '#66d9ef', // Blue base0E: '#ae81ff', // Purple base0F: '#cc6633' // Brown }; const CopyButton = ({ text, style }) => { const [copied, setCopied] = useState(false); const handleCopy = async () => { try { await Clipboard.setString(text || ''); // Ensure text is not null/undefined setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (error) { console.error("Failed to copy text: ", error); } }; return ( <Pressable style={[styles.copyButton, style]} onPress={handleCopy}> <Text style={styles.copyButtonText}>{copied ? 'Copied!' : 'Copy'}</Text> </Pressable> ); }; const CollapsibleSection = ({ title, children, initiallyExpanded = false }) => { const [expanded, setExpanded] = useState(initiallyExpanded); return ( <View style={styles.collapsibleSection}> <Pressable style={styles.sectionHeader} onPress={() => setExpanded(!expanded)}> <Text style={styles.sectionTitle}>{title}</Text> <Text style={styles.expandIcon}>{expanded ? '▼' : '▶'}</Text> </Pressable> {expanded && children} </View> ); }; const SubViewPerformance = () => { const [appLaunchMetrics, setAppLaunchMetrics] = useState(null); const [markEntries, setMarkEntries] = useState([]); const [measureEntries, setMeasureEntries] = useState([]); const [resourceEntries, setResourceEntries] = useState([]); const [metricEntries, setMetricEntries] = useState([]); const [nativeMarkEntries, setNativeMarkEntries] = useState([]); useEffect(() => { // Function to calculate and create app launch metrics from available marks const calculateAppLaunchMetrics = () => { try { // Attempt to get necessary marks. May throw if performance object not fully ready. const nativeLaunchStartMark = performance.getEntriesByName('nativeLaunchStart', 'mark')[0]; const nativeLaunchEndMark = performance.getEntriesByName('nativeLaunchEnd', 'mark')[0]; const runJsBundleStartMark = performance.getEntriesByName('runJsBundleStart', 'mark')[0]; const runJsBundleEndMark = performance.getEntriesByName('runJsBundleEnd', 'mark')[0]; const contentAppearedMark = performance.getEntriesByName('contentAppeared', 'mark')[0]; // Only create measure if both start and end marks exist if (nativeLaunchStartMark && nativeLaunchEndMark) { performance.measure('nativeLaunch', 'nativeLaunchStart', 'nativeLaunchEnd'); } if (runJsBundleStartMark && runJsBundleEndMark) { performance.measure('runJsBundle', 'runJsBundleStart', 'runJsBundleEnd'); } // Update state with calculated metrics - use helper functions for safety setAppLaunchMetrics({ appStartTime: performance.timeOrigin, // Time origin is relatively stable nativeLaunch: getMeasureValue('nativeLaunch'), jsBundle: getMeasureValue('runJsBundle'), timeToContentAppeared: contentAppearedMark ? contentAppearedMark.startTime - performance.timeOrigin : null, }); } catch (e) { // Log politely, as marks might appear later or not at all (e.g., in debug) // console.log('Could not calculate all launch metrics, some marks might be missing or performance API not ready:', e?.message); // Set partial or null metrics if calculation fails or marks aren't ready setAppLaunchMetrics(prev => ({ ...prev, // Keep any previously calculated metrics appStartTime: performance.timeOrigin, nativeLaunch: prev?.nativeLaunch ?? getMeasureValue('nativeLaunch'), // Try getting again jsBundle: prev?.jsBundle ?? getMeasureValue('runJsBundle'), timeToContentAppeared: prev?.timeToContentAppeared ?? (getMarkTime('contentAppeared') ? getMarkTime('contentAppeared') - performance.timeOrigin : null), })); } }; // Add a custom metric for demonstration when the component mounts performance.metric('SubViewPerformance.Mounted', Date.now()); // Set up observers for different entry types const observers = []; // Keep track of observers for cleanup const setupObserver = (type, setter) => { try { const observer = new PerformanceObserver((list) => { // Use functional update to avoid stale state issues setter(prevEntries => { // Basic deduplication based on name and startTime const existingEntries = new Map(prevEntries.map(e => [`${e.name}-${e.startTime}`, e])); list.getEntries().forEach(entry => { existingEntries.set(`${entry.name}-${entry.startTime}`, entry); }); return Array.from(existingEntries.values()).sort((a, b) => a.startTime - b.startTime); }); // If native marks arrive, try recalculating launch metrics if (type === 'react-native-mark') { calculateAppLaunchMetrics(); } }); observer.observe({ type, buffered: true }); observers.push(observer); } catch (e) { console.error(`Failed to setup PerformanceObserver for type "${type}":`, e); } }; setupObserver('mark', setMarkEntries); setupObserver('measure', setMeasureEntries); setupObserver('resource', setResourceEntries); setupObserver('metric', setMetricEntries); setupObserver('react-native-mark', setNativeMarkEntries); // Add a mark when this specific view opens performance.mark('PerformanceViewOpened'); // Initial attempt to calculate metrics in case marks are already present when component mounts calculateAppLaunchMetrics(); // Clean up observers when component unmounts return () => { observers.forEach(observer => observer.disconnect()); }; }, []); // Empty dependency array ensures this runs only once on mount // Helper function to safely get measure value const getMeasureValue = (name) => { try { const entries = performance.getEntriesByName(name, 'measure'); return entries.length > 0 ? entries[0].duration : null; } catch (e) { // This can happen if performance API is accessed too early // console.log(`Could not get measure "${name}": ${e.message}`); return null; } }; // Helper function to safely get mark time const getMarkTime = (name) => { try { const entries = performance.getEntriesByName(name, 'mark'); return entries.length > 0 ? entries[0].startTime : null; } catch (e) { // console.log(`Could not get mark "${name}": ${e.message}`); return null; } }; // Format timestamps for display (show 1 decimal place) const formatDuration = (duration) => { if (duration == null || isNaN(duration)) return 'N/A'; return `${duration.toFixed(1)} ms`; }; // Format start time (relative to timeOrigin) const formatTime = (startTime) => { if (startTime == null || isNaN(startTime)) return 'N/A'; return `${startTime.toFixed(1)} ms`; }; // Helper to create a JSON summary for the Copy button const createEntriesSummary = (entries) => { if (!entries || entries.length === 0) { return JSON.stringify({ message: 'No entries recorded for this type.' }); } try { // Stringify the current state of entries return JSON.stringify(entries, null, 2); // Pretty print JSON } catch (e) { console.error("Error stringifying performance entries: ", e); return 'Error creating summary'; } }; // --- Render Logic --- // Render JSON tree for various entry types const renderEntryList = (entries, type) => ( <View style={styles.dataSection}> <View style={styles.dataSectionHeader}> <Text style={styles.dataSectionTitle}>Entries</Text> <CopyButton text={createEntriesSummary(entries)} /> </View> <View style={styles.dataContentWrapper}> <ScrollView style={styles.dataContent} nestedScrollEnabled={true} bounces={false} showsVerticalScrollIndicator={true}> {entries.length === 0 ? ( <Text style={styles.noEntriesText}>No entries recorded yet.</Text> ) : ( <JSONTree data={entries} theme={theme} invertTheme={false} // Use the theme as is hideRoot={true} shouldExpandNode={(keyPath, data, level) => level < 1} // Expand only top level by default valueRenderer={(raw, value) => { // Shorten long strings in preview if (typeof value === 'string' && value.length > 100) { return <Text style={{ color: theme.base0B }}>{`"${value.substring(0, 100)}..."`}</Text>; } return <Text style={{ color: theme.base0B }}>{raw}</Text> }} labelRenderer={([key]) => <Text style={{ color: theme.base0D }}>{key}:</Text>} getItemString={(itemType, data) => { // Provide a concise summary string for collapsed items let summary = `${itemType} ${data.name || '(anonymous)'}`; if (data.entryType === 'measure' || data.entryType === 'resource') { summary += ` (${formatDuration(data.duration)})`; } else if (data.entryType === 'mark') { summary += ` @ ${formatTime(data.startTime)}`; } else if (data.entryType === 'metric') { summary += `: ${data.value}`; } return <Text style={{color: theme.base05}}>{summary}</Text>; }} /> )} </ScrollView> </View> </View> ); return ( <ScrollView style={styles.container} contentContainerStyle={styles.contentContainer} showsVerticalScrollIndicator={true}> <CollapsibleSection title='App Launch Performance' initiallyExpanded={true}> <View style={styles.content}> <Text style={styles.sectionDescription}> Key metrics for app startup. Relative to JS context creation (`timeOrigin`). </Text> <View style={styles.metricsGrid}> <View style={styles.metricItem}> <Text style={styles.metricValue} selectable={true}> {formatDuration(appLaunchMetrics?.nativeLaunch)} </Text> <Text style={styles.metricLabel}>Native Init</Text> </View> <View style={styles.metricItem}> <Text style={styles.metricValue} selectable={true}> {formatDuration(appLaunchMetrics?.jsBundle)} </Text> <Text style={styles.metricLabel}>JS Load</Text> </View> <View style={styles.metricItem}> <Text style={styles.metricValue} selectable={true}> {formatDuration(appLaunchMetrics?.timeToContentAppeared)} </Text> <Text style={styles.metricLabel}>Content Ready</Text> </View> </View> <Text style={styles.footnote}>Note: Values depend on native marks being available. 'N/A' indicates missing marks or calculation pending.</Text> </View> </CollapsibleSection> <CollapsibleSection title={`Native Marks (${nativeMarkEntries.length})`} initiallyExpanded={false}> <View style={styles.content}> <Text style={styles.sectionDescription}> Native events recorded during the app lifecycle. Times relative to `timeOrigin`. </Text> {renderEntryList(nativeMarkEntries, 'react-native-mark')} </View> </CollapsibleSection> <CollapsibleSection title={`Network Resources (${resourceEntries.length})`} initiallyExpanded={false}> <View style={styles.content}> <Text style={styles.sectionDescription}> Performance of network requests (fetch/XHR). Requires `setResourceLoggingEnabled(true)`. </Text> {renderEntryList(resourceEntries, 'resource')} </View> </CollapsibleSection> <CollapsibleSection title={`Marks (${markEntries.length})`} initiallyExpanded={false}> <View style={styles.content}> <Text style={styles.sectionDescription}> Custom points in time recorded via `performance.mark()`. Times relative to `timeOrigin`. </Text> {renderEntryList(markEntries, 'mark')} </View> </CollapsibleSection> <CollapsibleSection title={`Measures (${measureEntries.length})`} initiallyExpanded={false}> <View style={styles.content}> <Text style={styles.sectionDescription}> Durations recorded via `performance.measure()`. </Text> {renderEntryList(measureEntries, 'measure')} </View> </CollapsibleSection> <CollapsibleSection title={`Custom Metrics (${metricEntries.length})`} initiallyExpanded={false}> <View style={styles.content}> <Text style={styles.sectionDescription}> Custom values recorded via `performance.metric()`. </Text> {renderEntryList(metricEntries, 'metric')} </View> </CollapsibleSection> <CollapsibleSection title='Usage Info'> <View style={styles.content}> <Text style={styles.sectionDescription}> Add custom marks/measures/metrics in your code using the `react-native-performance` API: </Text> <View style={styles.codeBlockContainer}> <Text style={styles.codeText} selectable={true}> import performance from 'react-native-performance'; {`\n\n// --- Marks (points in time) ---`} {`\nperformance.mark('myFeatureStart');`} {`\nperformance.mark('complexCalcDone', {`} {`\n detail: { inputSize: 1000 }`} {`\n});`} {`\n\n// --- Measures (durations) ---`} {`\nperformance.measure('myFeature', 'myFeatureStart', 'myFeatureEnd');`} {`\nperformance.measure('complexCalc', { start: 'calcStart', end: 'complexCalcDone' });`} {`\n\n// --- Metrics (custom values) ---`} {`\nperformance.metric('cacheHitRate', 0.95);`} {`\nperformance.metric('itemsProcessed', 500);`} </Text> </View> </View> </CollapsibleSection> </ScrollView> ); }; const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#f0f0f0', // Slightly off-white background for the whole view }, contentContainer: { paddingBottom: 20, }, content: { paddingHorizontal: 15, paddingVertical: 10, // Reduced vertical padding within content sections backgroundColor: '#ffffff', // White background for content areas }, sectionDescription: { fontSize: 13, color: '#444', // Darker grey for descriptions marginBottom: 12, lineHeight: 18, }, footnote: { fontSize: 11, color: '#777', // Lighter grey for footnotes marginTop: 8, fontStyle: 'italic', }, collapsibleSection: { marginBottom: 1, // Thinner separator backgroundColor: '#fff', borderBottomWidth: 1, borderBottomColor: '#e5e5e5', }, sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 12, // Adjusted padding paddingHorizontal: 15, backgroundColor: '#f8f8f8', // Slightly lighter header background }, sectionTitle: { fontSize: 15, fontWeight: '600', // Semi-bold title color: '#333', }, expandIcon: { fontSize: 16, // Slightly larger icon color: '#555', }, metricsGrid: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', // Space between items marginBottom: 5, }, metricItem: { width: '31%', // Adjusted for spacing backgroundColor: '#f5f9ff', // Lighter blue background borderRadius: 6, paddingVertical: 10, paddingHorizontal: 5, marginBottom: 10, alignItems: 'center', minHeight: 65, // Ensure consistent height justifyContent: 'center', borderWidth: 1, borderColor: '#e0e8f0', }, metricValue: { fontSize: 15, fontWeight: '600', color: '#0052cc', // Darker blue for value marginBottom: 4, textAlign: 'center', }, metricLabel: { fontSize: 11, color: '#505050', // Darker grey label textAlign: 'center', fontWeight: '500', }, dataSection: { // Removed marginBottom, handled by collapsibleSection border }, dataSectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8, paddingBottom: 6, borderBottomWidth: 1, borderBottomColor: '#eee', }, dataSectionTitle: { fontSize: 14, fontWeight: '600', color: '#444', }, dataContentWrapper: { flex: 1, maxHeight: 350, // Limit height of scrollable area }, dataContent: { flexGrow: 1, backgroundColor: theme.base00, // Use theme background padding: 10, borderRadius: 4, }, noEntriesText: { color: theme.base04, // Use a theme color for placeholder text fontStyle: 'italic', textAlign: 'center', paddingVertical: 20, }, copyButton: { backgroundColor: '#e8e8e8', // Lighter grey button paddingHorizontal: 12, // More padding paddingVertical: 6, borderRadius: 4, }, copyButtonText: { fontSize: 12, color: '#444', // Darker text fontWeight: '500', }, codeBlockContainer: { backgroundColor: '#f7f7f7', padding: 15, borderRadius: 4, borderWidth: 1, borderColor: '#e0e0e0', }, codeText: { fontSize: 12.5, // Slightly smaller code font color: '#333', fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace', lineHeight: 17, }, }); export default SubViewPerformance;