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
JavaScript
// 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;