diff --git a/scripts/convertTrace.js b/scripts/convertTrace.js new file mode 100644 index 00000000000..7a40cacc0d5 --- /dev/null +++ b/scripts/convertTrace.js @@ -0,0 +1,537 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const ID_MAP = { + 'Scheduler \u269B': 'scheduler', + Scheduler: 'scheduler', + 'Components \u269B': 'components', + Components: 'components', + Blocking: 'blocking', + Transition: 'transition', + Suspense: 'suspense', + Idle: 'idle', +}; + +function toId(name) { + return ( + ID_MAP[name] || + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + ); +} + +// Map entry name → {type, name?} for scheduler track entries +function inferSchedulerType(entryName) { + if (entryName.startsWith('Event: ')) { + return {type: 'event', name: entryName.slice(7)}; + } + const SCHEDULER_TYPES = { + Render: 'render', + Commit: 'commit', + Update: 'update', + Action: 'action', + Suspended: 'suspended', + Prewarm: 'prewarm', + 'Remaining Effects': 'remaining-effects', + Waiting: 'waiting', + 'Waiting for Paint': 'waiting', + }; + const type = SCHEDULER_TYPES[entryName]; + if (type) { + const TYPE_LABELS = { + render: 'Render', + commit: 'Commit', + update: 'Update', + action: 'Action', + suspended: 'Suspended', + prewarm: 'Prewarm', + 'remaining-effects': 'Remaining Effects', + waiting: 'Waiting', + }; + const needsName = entryName !== TYPE_LABELS[type]; + return needsName ? {type, name: entryName} : {type}; + } + return {type: entryName.toLowerCase().replace(/\s+/g, '-'), name: entryName}; +} + +// Map component track entry → {type, name?, perf?} +function inferComponentType(entryName, color) { + if (entryName === 'Mount') return {type: 'mount'}; + if (entryName === 'Unmount') return {type: 'unmount'}; + // Component render — perf tier derived from color + const result = {type: 'render', name: entryName}; + const perf = colorToPerf(color); + if (perf > 1) result.perf = perf; + return result; +} + +// Reverse-map trace color → perf tier +function colorToPerf(color) { + if (color === 'primary' || color === 'secondary') return 2; + if (color === 'primary-dark' || color === 'secondary-dark') return 3; + return 1; // primary-light, secondary-light, or default +} + +// Extract extension track entries from performance.measure() events +// These have cat: "blink.user_timing" with args.detail containing {devtools: {...}} +function extractPerformanceAPIEntries(events) { + const beginEvents = new Map(); // key -> event + const entries = []; + + for (const event of events) { + if (!event.cat || !event.cat.includes('blink.user_timing')) { + continue; + } + + // Handle end events before metadata check — they pair by key + // and use the begin event's metadata (end events have args: {}) + if (event.ph === 'e') { + const key = `${event.id || event.id2?.local || event.id2?.global}-${ + event.cat + }-${event.name}`; + const begin = beginEvents.get(key); + if (begin) { + entries.push({ + name: event.name, + ts: begin.event.ts, + dur: event.ts - begin.event.ts, + track: begin.devtools.track, + trackGroup: begin.devtools.trackGroup, + color: begin.devtools.color, + dataType: begin.devtools.dataType || 'track-entry', + }); + beginEvents.delete(key); + } + continue; + } + + // Try to parse devtools metadata from detail + let detail = null; + const detailStr = event.args?.detail || event.args?.data?.detail; + if (typeof detailStr === 'string') { + try { + detail = JSON.parse(detailStr); + } catch (e) { + continue; + } + } else if (typeof detailStr === 'object' && detailStr !== null) { + detail = detailStr; + } + + if (!detail || !detail.devtools) { + continue; + } + + const devtools = detail.devtools; + + if (event.ph === 'b') { + // Begin of async pair + const key = `${event.id || event.id2?.local || event.id2?.global}-${ + event.cat + }-${event.name}`; + beginEvents.set(key, {event, devtools}); + } else if (event.ph === 'I' || event.ph === 'R' || event.ph === 'n') { + // Instant event (marker) + entries.push({ + name: event.name, + ts: event.ts, + dur: 0, + track: devtools.track, + trackGroup: devtools.trackGroup, + color: devtools.color, + dataType: devtools.dataType || 'marker', + }); + } + } + + return entries; +} + +// Extract extension track entries from console.timeStamp() events +// These have cat: "devtools.timeline", name: "TimeStamp", with args.data.track +function extractConsoleTimestampEntries(events) { + const entries = []; + const registeredTracks = []; // {track, trackGroup} from registration markers + const namedTimestamps = new Map(); // name -> ts + + // First pass: collect named timestamps for start/end references + for (const event of events) { + if ( + event.cat === 'devtools.timeline' && + event.name === 'TimeStamp' && + event.args?.data?.name != null + ) { + namedTimestamps.set(String(event.args.data.name), event.ts); + } + } + + // Second pass: extract entries + for (const event of events) { + if (event.cat !== 'devtools.timeline' || event.name !== 'TimeStamp') { + continue; + } + + const data = event.args?.data; + if (!data || data.track == null) { + continue; + } + + let startTs = event.ts; + let endTs = event.ts; + + // Resolve start reference + if (data.start != null) { + if (typeof data.start === 'number') { + startTs = data.start; + } else { + const ref = namedTimestamps.get(String(data.start)); + if (ref != null) startTs = ref; + } + } + + // Resolve end reference + if (data.end != null) { + if (typeof data.end === 'number') { + endTs = data.end; + } else { + const ref = namedTimestamps.get(String(data.end)); + if (ref != null) endTs = ref; + } + } + + // Skip track registration markers (zero-duration entries that just register the track) + // but remember the track so it appears in output with empty entries + if (startTs === endTs && data.message && data.message.endsWith(' Track')) { + registeredTracks.push({ + track: String(data.track), + trackGroup: + data.trackGroup != null ? String(data.trackGroup) : undefined, + }); + continue; + } + + entries.push({ + name: data.message || '', + ts: startTs, + dur: Math.max(0, endTs - startTs), + track: String(data.track), + trackGroup: data.trackGroup != null ? String(data.trackGroup) : undefined, + color: data.color != null ? String(data.color) : 'primary', + dataType: 'track-entry', + }); + } + + return {entries, registeredTracks}; +} + +// Compute flamegraph depth for entries within a track +// Entries that overlap in time get increasing depth +function computeDepths(entries) { + // Sort by start time, then by duration descending (parents before children) + entries.sort((a, b) => a.start - b.start || b.duration - a.duration); + + const active = []; // stack of {end, depth} + for (const entry of entries) { + // Remove entries that have ended + const stillActive = active.filter((a) => a.end > entry.start + 0.001); + active.length = 0; + active.push(...stillActive); + + // Find the next available depth + const usedDepths = new Set(active.map((a) => a.depth)); + let depth = 0; + while (usedDepths.has(depth)) { + depth++; + } + + entry.depth = depth; + if (entry.duration > 0) { + active.push({end: entry.start + entry.duration, depth}); + } + } +} + +// Normalize raw entries into tracks with proportional start/duration and depth +// This encapsulates: µs → proportional unit conversion, track grouping, overlap fixup, and depth computation +function normalizeEntries(rawEntries, registeredTracks = []) { + if (rawEntries.length === 0) { + // Still include registered tracks even with no entries + const tracks = {}; + for (const reg of registeredTracks) { + const groupId = reg.trackGroup ? toId(reg.trackGroup) : toId(reg.track); + if (reg.trackGroup) { + if (!tracks[groupId]) tracks[groupId] = {}; + tracks[groupId][toId(reg.track)] = []; + } else { + tracks[groupId] = []; + } + } + return {duration: 0, tracks}; + } + + // Normalize to proportional units (shortest duration = 1) + const minTs = Math.min(...rawEntries.map((e) => e.ts)); + const nonZeroDurations = rawEntries + .filter((e) => e.dur > 0) + .map((e) => e.dur); + const minDur = + nonZeroDurations.length > 0 ? Math.min(...nonZeroDurations) : 1000; + for (const entry of rawEntries) { + entry.start = Math.round((entry.ts - minTs) / minDur); + entry.duration = + entry.dur > 0 ? Math.max(1, Math.round(entry.dur / minDur)) : 0; + } + + // Group by track + const trackMap = new Map(); + + // Ensure registered tracks exist (even if they have no entries) + for (const reg of registeredTracks) { + const trackKey = `${reg.trackGroup || ''}::${reg.track}`; + if (!trackMap.has(trackKey)) { + trackMap.set(trackKey, { + name: reg.track, + trackGroup: reg.trackGroup, + entries: [], + }); + } + } + + for (const entry of rawEntries) { + const trackKey = `${entry.trackGroup || ''}::${entry.track}`; + if (!trackMap.has(trackKey)) { + trackMap.set(trackKey, { + name: entry.track, + trackGroup: entry.trackGroup, + entries: [], + }); + } + trackMap.get(trackKey).entries.push({ + name: entry.name, + start: entry.start, + duration: entry.duration, + color: entry.color || 'primary', + depth: 0, + _ts: entry.ts, // preserve for overlap fixup + _dur: entry.dur, // preserve for overlap fixup + }); + } + + // Per-track overlap fixup: fix sequential entries that overlap only due to rounding + for (const track of trackMap.values()) { + // Sort by raw timestamp, then raw duration desc (parents before children) + track.entries.sort((a, b) => a._ts - b._ts || b._dur - a._dur); + for (let i = 1; i < track.entries.length; i++) { + const prev = track.entries[i - 1]; + const curr = track.entries[i]; + const prevEnd = prev.start + prev.duration; + const prevRawEnd = prev._ts + prev._dur; + // Only fix if they were sequential in raw data but overlap after rounding + if (curr._ts >= prevRawEnd && curr.start < prevEnd) { + curr.start = prevEnd; + curr._shifted = true; + } + } + } + + // Cross-track shift propagation: when a shifted entry moves forward, + // entries on other tracks whose raw timestamp falls within the shifted + // entry's raw time range should move to at least the same start position + const shiftedEntries = []; + for (const track of trackMap.values()) { + for (const entry of track.entries) { + if (entry._shifted) { + shiftedEntries.push(entry); + } + } + } + if (shiftedEntries.length > 0) { + for (const track of trackMap.values()) { + for (const entry of track.entries) { + if (!entry._shifted) { + for (const shifted of shiftedEntries) { + if ( + entry._ts >= shifted._ts && + entry._ts < shifted._ts + shifted._dur + ) { + entry.start = Math.max(entry.start, shifted.start); + } + } + } + } + } + // Re-run per-track overlap fixup to resolve any new overlaps + // created by cross-track propagation + for (const track of trackMap.values()) { + track.entries.sort((a, b) => a._ts - b._ts || b._dur - a._dur); + for (let i = 1; i < track.entries.length; i++) { + const prev = track.entries[i - 1]; + const curr = track.entries[i]; + const prevEnd = prev.start + prev.duration; + const prevRawEnd = prev._ts + prev._dur; + if (curr._ts >= prevRawEnd && curr.start < prevEnd) { + curr.start = prevEnd; + } + } + } + } + + // Compute depths + for (const track of trackMap.values()) { + computeDepths(track.entries); + } + + // Build output (strip internal fields, compute total duration) + let maxEnd = 0; + for (const track of trackMap.values()) { + for (const entry of track.entries) { + maxEnd = Math.max(maxEnd, entry.start + entry.duration); + } + } + const tracks = {}; + for (const track of trackMap.values()) { + const isComponentTrack = + track.name === 'Components' || track.name === 'Components ⚛'; + const cleanEntries = track.entries.map((e) => { + const typeInfo = isComponentTrack + ? inferComponentType(e.name, e.color) + : inferSchedulerType(e.name); + + const result = { + type: typeInfo.type, + start: e.start, + duration: e.duration, + }; + if (typeInfo.name) result.name = typeInfo.name; + if (typeInfo.perf > 1) result.perf = typeInfo.perf; + if (e.depth > 0) result.depth = e.depth; + return result; + }); + const groupId = track.trackGroup + ? toId(track.trackGroup) + : toId(track.name); + if (track.trackGroup) { + if (!tracks[groupId]) tracks[groupId] = {}; + tracks[groupId][toId(track.name)] = cleanEntries; + } else { + tracks[groupId] = cleanEntries; + } + } + + return {duration: Math.ceil(maxEnd), tracks}; +} + +// Export functions for testing +module.exports = { + extractPerformanceAPIEntries, + extractConsoleTimestampEntries, + computeDepths, + normalizeEntries, +}; + +// CLI entry point — only runs when executed directly +if (require.main === module) { + const fs = require('fs'); + const path = require('path'); + + // Parse CLI args + const args = process.argv.slice(2); + const flags = {}; + const positional = []; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--start' && i + 1 < args.length) { + flags.start = parseFloat(args[++i]); + } else if (args[i] === '--end' && i + 1 < args.length) { + flags.end = parseFloat(args[++i]); + } else if (args[i] === '--pretty') { + flags.pretty = true; + } else if (args[i] === '--copy') { + flags.copy = true; + } else if (args[i] === '--help' || args[i] === '-h') { + console.log( + 'Usage: node scripts/convertTrace.js [--start ] [--end ] [--pretty] [--copy]' + ); + console.log(''); + console.log( + 'Extracts React DevTools extension track data from a Chrome Performance trace.' + ); + console.log( + 'Outputs a JSX snippet for the MDX component.' + ); + console.log(''); + console.log('Options:'); + console.log( + ' --start Only include entries starting at or after this time' + ); + console.log( + ' --end Only include entries starting at or before this time' + ); + console.log(' --pretty Pretty-print the JSON data'); + console.log(' --copy Copy output to clipboard (macOS pbcopy)'); + process.exit(0); + } else { + positional.push(args[i]); + } + } + + const traceFile = positional[0]; + if (!traceFile) { + console.error( + 'Usage: node scripts/convertTrace.js [--start ] [--end ] [--pretty] [--copy]' + ); + process.exit(1); + } + + // Read and parse trace + const raw = fs.readFileSync(path.resolve(traceFile), 'utf8'); + const parsed = JSON.parse(raw); + const traceEvents = Array.isArray(parsed) ? parsed : parsed.traceEvents || []; + + // Main + const perfEntries = extractPerformanceAPIEntries(traceEvents); + const {entries: timestampEntries, registeredTracks} = + extractConsoleTimestampEntries(traceEvents); + const allEntries = [...perfEntries, ...timestampEntries]; + + if (allEntries.length === 0) { + console.error('No extension track entries found in trace.'); + process.exit(1); + } + + // Apply optional time range filter (on raw µs timestamps, converted to ms offsets) + if (flags.start != null || flags.end != null) { + const minTs = Math.min(...allEntries.map((e) => e.ts)); + const startFilter = flags.start || 0; + const endFilter = flags.end || Infinity; + const filtered = allEntries.filter((e) => { + const startMs = Math.round((e.ts - minTs) / 1000); + const durMs = e.dur > 0 ? Math.max(1, Math.round(e.dur / 1000)) : 0; + return startMs + durMs >= startFilter && startMs <= endFilter; + }); + allEntries.length = 0; + allEntries.push(...filtered); + } + + const result = normalizeEntries(allEntries, registeredTracks); + + const json = flags.pretty + ? JSON.stringify(result, null, 2) + : JSON.stringify(result); + + const cliOutput = ``; + + if (flags.copy) { + require('child_process').execSync('pbcopy', {input: cliOutput}); + } + + console.log(cliOutput); +} diff --git a/scripts/convertTrace.test.js b/scripts/convertTrace.test.js new file mode 100644 index 00000000000..25e252b02e3 --- /dev/null +++ b/scripts/convertTrace.test.js @@ -0,0 +1,680 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const {describe, it} = require('node:test'); +const assert = require('node:assert/strict'); +const { + extractPerformanceAPIEntries, + extractConsoleTimestampEntries, + computeDepths, + normalizeEntries, +} = require('./convertTrace'); + +// Helper: create a begin event (ph: "b") with devtools metadata +function beginEvent({name, id, ts, track, trackGroup, color, dataType}) { + return { + ph: 'b', + cat: 'blink.user_timing', + name, + id, + ts, + args: { + detail: JSON.stringify({ + devtools: {track, trackGroup, color, dataType}, + }), + }, + }; +} + +// Helper: create an end event (ph: "e") — no devtools metadata, just like Chrome +function endEvent({name, id, ts}) { + return { + ph: 'e', + cat: 'blink.user_timing', + name, + id, + ts, + args: {}, + }; +} + +// Helper: create an instant event (ph: "I") with devtools metadata +function instantEvent({name, ts, track, trackGroup, color, dataType}) { + return { + ph: 'I', + cat: 'blink.user_timing', + name, + ts, + args: { + detail: JSON.stringify({ + devtools: {track, trackGroup, color, dataType}, + }), + }, + }; +} + +// Helper: create a console.timeStamp event +function timestampEvent({ + ts, + track, + trackGroup, + message, + color, + start, + end, + name, +}) { + const data = {track, message}; + if (trackGroup != null) data.trackGroup = trackGroup; + if (color != null) data.color = color; + if (start != null) data.start = start; + if (end != null) data.end = end; + if (name != null) data.name = name; + return { + cat: 'devtools.timeline', + name: 'TimeStamp', + ts, + args: {data}, + }; +} + +// ============================================================ +// extractPerformanceAPIEntries +// ============================================================ +describe('extractPerformanceAPIEntries', () => { + it('pairs begin and end events into a single entry', () => { + const events = [ + beginEvent({ + name: 'Render', + id: '0x1', + ts: 1000, + track: 'Blocking', + trackGroup: 'Scheduler', + color: 'primary-dark', + }), + endEvent({name: 'Render', id: '0x1', ts: 5000}), + ]; + const entries = extractPerformanceAPIEntries(events); + assert.equal(entries.length, 1); + assert.equal(entries[0].name, 'Render'); + assert.equal(entries[0].ts, 1000); + assert.equal(entries[0].dur, 4000); + assert.equal(entries[0].track, 'Blocking'); + assert.equal(entries[0].trackGroup, 'Scheduler'); + assert.equal(entries[0].color, 'primary-dark'); + }); + + it('handles end events with no devtools metadata (the original bug)', () => { + // This is the exact scenario that was broken: end events have args: {} + // and were being dropped by the devtools metadata check + const events = [ + beginEvent({ + name: 'Update', + id: '0xA', + ts: 2000, + track: 'Blocking', + trackGroup: 'Scheduler', + color: 'primary-light', + }), + // End event — no detail, no devtools in args + { + ph: 'e', + cat: 'blink.user_timing', + name: 'Update', + id: '0xA', + ts: 3000, + args: {}, + }, + ]; + const entries = extractPerformanceAPIEntries(events); + assert.equal(entries.length, 1, 'end event should not be dropped'); + assert.equal(entries[0].name, 'Update'); + assert.equal(entries[0].dur, 1000); + }); + + it('matches begin/end by id + cat + name', () => { + const events = [ + beginEvent({ + name: 'Render', + id: '0x1', + ts: 1000, + track: 'Blocking', + trackGroup: 'Scheduler', + color: 'primary-dark', + }), + beginEvent({ + name: 'Render', + id: '0x2', + ts: 2000, + track: 'Transition', + trackGroup: 'Scheduler', + color: 'primary-dark', + }), + endEvent({name: 'Render', id: '0x2', ts: 4000}), + endEvent({name: 'Render', id: '0x1', ts: 6000}), + ]; + const entries = extractPerformanceAPIEntries(events); + assert.equal(entries.length, 2); + // Sorted by when end events arrive + assert.equal(entries[0].track, 'Transition'); + assert.equal(entries[0].dur, 2000); + assert.equal(entries[1].track, 'Blocking'); + assert.equal(entries[1].dur, 5000); + }); + + it('extracts instant events (markers)', () => { + const events = [ + instantEvent({ + name: 'Promise Resolved', + ts: 5000, + track: 'Transition', + trackGroup: 'Scheduler', + color: 'primary-light', + dataType: 'marker', + }), + ]; + const entries = extractPerformanceAPIEntries(events); + assert.equal(entries.length, 1); + assert.equal(entries[0].name, 'Promise Resolved'); + assert.equal(entries[0].dur, 0); + assert.equal(entries[0].dataType, 'marker'); + }); + + it('ignores events without blink.user_timing category', () => { + const events = [ + {ph: 'b', cat: 'v8', name: 'Something', id: '0x1', ts: 1000, args: {}}, + {ph: 'e', cat: 'v8', name: 'Something', id: '0x1', ts: 2000, args: {}}, + ]; + const entries = extractPerformanceAPIEntries(events); + assert.equal(entries.length, 0); + }); + + it('ignores orphan end events (no matching begin)', () => { + const events = [endEvent({name: 'Orphan', id: '0x99', ts: 5000})]; + const entries = extractPerformanceAPIEntries(events); + assert.equal(entries.length, 0); + }); + + it('uses id2.local when id is absent', () => { + const events = [ + { + ph: 'b', + cat: 'blink.user_timing', + name: 'Render', + id2: {local: '0xABC'}, + ts: 1000, + args: { + detail: JSON.stringify({ + devtools: {track: 'Blocking', color: 'primary-dark'}, + }), + }, + }, + { + ph: 'e', + cat: 'blink.user_timing', + name: 'Render', + id2: {local: '0xABC'}, + ts: 3000, + args: {}, + }, + ]; + const entries = extractPerformanceAPIEntries(events); + assert.equal(entries.length, 1); + assert.equal(entries[0].dur, 2000); + }); + + it('handles detail as object (not JSON string)', () => { + const events = [ + { + ph: 'b', + cat: 'blink.user_timing', + name: 'Commit', + id: '0x1', + ts: 1000, + args: { + detail: { + devtools: {track: 'Blocking', color: 'secondary-dark'}, + }, + }, + }, + endEvent({name: 'Commit', id: '0x1', ts: 2000}), + ]; + const entries = extractPerformanceAPIEntries(events); + assert.equal(entries.length, 1); + assert.equal(entries[0].color, 'secondary-dark'); + }); + + it('handles detail in args.data.detail', () => { + const events = [ + { + ph: 'b', + cat: 'blink.user_timing', + name: 'Commit', + id: '0x1', + ts: 1000, + args: { + data: { + detail: JSON.stringify({ + devtools: {track: 'Blocking', color: 'secondary-dark'}, + }), + }, + }, + }, + endEvent({name: 'Commit', id: '0x1', ts: 2000}), + ]; + const entries = extractPerformanceAPIEntries(events); + assert.equal(entries.length, 1); + assert.equal(entries[0].color, 'secondary-dark'); + }); + + it('defaults dataType to track-entry for duration events', () => { + const events = [ + beginEvent({ + name: 'Render', + id: '0x1', + ts: 1000, + track: 'Blocking', + color: 'primary-dark', + // no dataType + }), + endEvent({name: 'Render', id: '0x1', ts: 2000}), + ]; + const entries = extractPerformanceAPIEntries(events); + assert.equal(entries[0].dataType, 'track-entry'); + }); + + it('produces multiple entries from interleaved begin/end pairs', () => { + // Simulate: Event:mousedown, Update, Render on Blocking track + const events = [ + beginEvent({ + name: 'Event: mousedown', + id: '0x1', + ts: 0, + track: 'Blocking', + trackGroup: 'Scheduler', + color: 'warning', + }), + endEvent({name: 'Event: mousedown', id: '0x1', ts: 12000}), + beginEvent({ + name: 'Update', + id: '0x2', + ts: 12000, + track: 'Blocking', + trackGroup: 'Scheduler', + color: 'primary-light', + }), + endEvent({name: 'Update', id: '0x2', ts: 13000}), + beginEvent({ + name: 'Render', + id: '0x3', + ts: 13000, + track: 'Blocking', + trackGroup: 'Scheduler', + color: 'primary-dark', + }), + endEvent({name: 'Render', id: '0x3', ts: 17000}), + ]; + const entries = extractPerformanceAPIEntries(events); + assert.equal(entries.length, 3); + assert.deepEqual( + entries.map((e) => e.name), + ['Event: mousedown', 'Update', 'Render'] + ); + }); +}); + +// ============================================================ +// extractConsoleTimestampEntries +// ============================================================ +describe('extractConsoleTimestampEntries', () => { + it('extracts basic timestamp entries', () => { + const events = [ + timestampEvent({ + ts: 1000, + track: 'MyTrack', + message: 'Step 1', + color: 'primary', + start: 1000, + end: 5000, + }), + ]; + const {entries} = extractConsoleTimestampEntries(events); + assert.equal(entries.length, 1); + assert.equal(entries[0].name, 'Step 1'); + assert.equal(entries[0].ts, 1000); + assert.equal(entries[0].dur, 4000); + }); + + it('collects registered tracks', () => { + const events = [ + timestampEvent({ + ts: 1000, + track: 'Suspense', + trackGroup: 'Scheduler', + message: 'Suspense Track', + }), + ]; + const {entries, registeredTracks} = extractConsoleTimestampEntries(events); + assert.equal(entries.length, 0); + assert.equal(registeredTracks.length, 1); + assert.equal(registeredTracks[0].track, 'Suspense'); + assert.equal(registeredTracks[0].trackGroup, 'Scheduler'); + }); + + it('resolves named start/end references', () => { + const events = [ + // Named timestamp for reference + timestampEvent({ts: 2000, track: 'T', message: 'msg', name: 'ref-start'}), + timestampEvent({ts: 8000, track: 'T', message: 'msg', name: 'ref-end'}), + // Entry referencing them + timestampEvent({ + ts: 5000, + track: 'T', + message: 'Span', + start: 'ref-start', + end: 'ref-end', + }), + ]; + const {entries} = extractConsoleTimestampEntries(events); + // The entry referencing start/end by name + const span = entries.find((e) => e.name === 'Span'); + assert.ok(span); + assert.equal(span.ts, 2000); // resolved from ref-start + assert.equal(span.dur, 6000); // 8000 - 2000 + }); + + it('ignores events without track', () => { + const events = [ + { + cat: 'devtools.timeline', + name: 'TimeStamp', + ts: 1000, + args: {data: {message: 'no track'}}, + }, + ]; + const {entries} = extractConsoleTimestampEntries(events); + assert.equal(entries.length, 0); + }); + + it('defaults color to primary', () => { + const events = [ + timestampEvent({ + ts: 1000, + track: 'T', + message: 'x', + start: 1000, + end: 2000, + }), + ]; + const {entries} = extractConsoleTimestampEntries(events); + assert.equal(entries[0].color, 'primary'); + }); +}); + +// ============================================================ +// computeDepths +// ============================================================ +describe('computeDepths', () => { + it('assigns depth 0 to non-overlapping entries', () => { + const entries = [ + {name: 'A', start: 0, duration: 5}, + {name: 'B', start: 5, duration: 5}, + {name: 'C', start: 10, duration: 5}, + ]; + computeDepths(entries); + assert.deepEqual( + entries.map((e) => e.depth), + [0, 0, 0] + ); + }); + + it('assigns increasing depth to nested entries', () => { + const entries = [ + {name: 'Parent', start: 0, duration: 10}, + {name: 'Child', start: 1, duration: 5}, + {name: 'Grandchild', start: 2, duration: 2}, + ]; + computeDepths(entries); + assert.equal(entries[0].depth, 0); + assert.equal(entries[1].depth, 1); + assert.equal(entries[2].depth, 2); + }); + + it('assigns different depths to overlapping siblings', () => { + const entries = [ + {name: 'A', start: 0, duration: 10}, + {name: 'B', start: 0, duration: 10}, + ]; + computeDepths(entries); + const depths = entries.map((e) => e.depth).sort(); + assert.deepEqual(depths, [0, 1]); + }); + + it('reuses depth after an entry ends', () => { + const entries = [ + {name: 'A', start: 0, duration: 5}, + {name: 'B', start: 5, duration: 5}, + ]; + computeDepths(entries); + assert.equal(entries[0].depth, 0); + assert.equal(entries[1].depth, 0); + }); + + it('handles zero-duration entries', () => { + const entries = [ + {name: 'Marker', start: 5, duration: 0}, + {name: 'Another', start: 5, duration: 0}, + ]; + computeDepths(entries); + // Zero-duration entries don't occupy depth, so both get 0 + assert.equal(entries[0].depth, 0); + assert.equal(entries[1].depth, 0); + }); + + it('sorts entries by start time, then duration descending', () => { + const entries = [ + {name: 'Short', start: 0, duration: 2}, + {name: 'Long', start: 0, duration: 10}, + ]; + computeDepths(entries); + // After sorting, Long (duration 10) comes first at depth 0 + assert.equal(entries[0].name, 'Long'); + assert.equal(entries[0].depth, 0); + assert.equal(entries[1].name, 'Short'); + assert.equal(entries[1].depth, 1); + }); + + it('handles flamegraph-style nesting (parent contains child)', () => { + // Simulate: Render contains multiple sub-phases + const entries = [ + {name: 'Render', start: 0, duration: 20}, + {name: 'Reconcile', start: 0, duration: 10}, + {name: 'Layout', start: 10, duration: 5}, + {name: 'Paint', start: 15, duration: 5}, + ]; + computeDepths(entries); + assert.equal(entries[0].depth, 0); // Render + assert.equal(entries[1].depth, 1); // Reconcile + assert.equal(entries[2].depth, 1); // Layout (Reconcile ended, depth 1 is free) + assert.equal(entries[3].depth, 1); // Paint (Layout ended, depth 1 is free) + }); +}); + +// ============================================================ +// normalizeEntries +// ============================================================ +describe('normalizeEntries', () => { + it('fixes sequential entries that overlap after rounding', () => { + // Two sequential entries on the same track: Commit ends at 17800µs, Waiting starts at 17800µs + // Both round to start: 17ms, but Waiting should come after Commit + const entries = [ + { + name: 'Commit', + ts: 17200, + dur: 600, + track: 'Blocking', + color: 'secondary-dark', + }, + { + name: 'Waiting', + ts: 17800, + dur: 500, + track: 'Blocking', + color: 'secondary-light', + }, + ]; + const result = normalizeEntries(entries); + const blockingEntries = result.tracks.blocking; + const commit = blockingEntries.find((e) => e.type === 'commit'); + const waiting = blockingEntries.find((e) => e.type === 'waiting'); + // Commit rounds to start: 0, duration: 1 (600µs → 1ms) + assert.equal(commit.start, 0); + assert.equal(commit.duration, 1); + assert.equal(commit.color, undefined); + assert.equal(commit.name, undefined); + // Waiting should start after Commit ends, not at the same start + assert.equal(waiting.start, 1); + assert.equal(waiting.color, undefined); + assert.equal(waiting.name, undefined); + assert.equal(waiting.depth, undefined); // no depth since no overlap + }); + + it('keeps genuinely overlapping entries (parent/child) at different depths', () => { + // Parent contains child — they overlap in raw data + const entries = [ + {name: 'Parent', ts: 0, dur: 10000, track: 'T', color: 'primary'}, + {name: 'Child', ts: 2000, dur: 3000, track: 'T', color: 'primary'}, + ]; + const result = normalizeEntries(entries); + const trackEntries = result.tracks.t; + const parent = trackEntries.find((e) => e.name === 'Parent'); + const child = trackEntries.find((e) => e.name === 'Child'); + assert.equal(parent.type, 'parent'); + assert.equal(child.type, 'child'); + assert.equal(parent.depth, undefined); // depth 0 is omitted + assert.equal(child.depth, 1); + }); + + it('does not affect cross-track entries at the same raw time', () => { + // Two entries at the same timestamp on different tracks should both keep the same start + const entries = [ + {name: 'A', ts: 5000, dur: 1000, track: 'Track1', color: 'primary'}, + {name: 'B', ts: 5000, dur: 1000, track: 'Track2', color: 'primary'}, + ]; + const result = normalizeEntries(entries); + assert.equal(result.tracks.track1[0].start, 0); + assert.equal(result.tracks.track1[0].type, 'a'); + assert.equal(result.tracks.track2[0].start, 0); + assert.equal(result.tracks.track2[0].type, 'b'); + }); + + it('cascades fixup for multiple entries rounding to same start', () => { + // Pad entry on a different track sets minDur=100 and offsets A so rounding + // causes A's end to exceed B's rounded start, triggering cascade fixup. + // A: start=round(60/100)=1, dur=round(160/100)=2, end=3 + // B: start=round(220/100)=2 < 3 → pushed to 3 + // C: start=round(320/100)=3 < 4 → pushed to 4 + const entries = [ + {name: 'Pad', ts: 10000, dur: 100, track: 'Other', color: 'primary'}, + {name: 'A', ts: 10060, dur: 160, track: 'T', color: 'primary'}, + {name: 'B', ts: 10220, dur: 100, track: 'T', color: 'primary'}, + {name: 'C', ts: 10320, dur: 100, track: 'T', color: 'primary'}, + ]; + const result = normalizeEntries(entries); + const trackEntries = result.tracks.t; + const a = trackEntries.find((e) => e.name === 'A'); + const b = trackEntries.find((e) => e.name === 'B'); + const c = trackEntries.find((e) => e.name === 'C'); + assert.equal(a.start, 1); + assert.equal(b.start, 3); // B pushed after A (1 + 2) + assert.equal(c.start, 4); // C pushed after B (3 + 1) + }); + + it('does not push zero-duration entries', () => { + // A zero-duration marker at the same start should stay + const entries = [ + {name: 'Span', ts: 5000, dur: 1000, track: 'T', color: 'primary'}, + {name: 'Marker', ts: 5000, dur: 0, track: 'T', color: 'primary'}, + ]; + const result = normalizeEntries(entries); + const trackEntries = result.tracks.t; + const span = trackEntries.find((e) => e.name === 'Span'); + const marker = trackEntries.find((e) => e.name === 'Marker'); + assert.equal(span.start, 0); + assert.equal(marker.start, 0); + assert.equal(span.type, 'span'); + assert.equal(marker.type, 'marker'); + }); + + it('propagates overlap shifts to entries on other tracks within the shifted time range', () => { + // Setup sets minDur=100. Render overlaps with Commit after rounding. + // Button on another track has raw ts within Commit's raw range, so it shifts too. + const entries = [ + { + name: 'Setup', + ts: 0, + dur: 100, + track: 'Blocking', + trackGroup: 'Scheduler', + color: 'primary', + }, + { + name: 'Render', + ts: 160, + dur: 160, + track: 'Blocking', + trackGroup: 'Scheduler', + color: 'primary-dark', + }, + { + name: 'Commit', + ts: 320, + dur: 150, + track: 'Blocking', + trackGroup: 'Scheduler', + color: 'secondary-dark', + }, + { + name: 'Button', + ts: 330, + dur: 100, + track: 'Components', + color: 'primary-light', + }, + ]; + const result = normalizeEntries(entries); + const blockingEntries = result.tracks.scheduler.blocking; + const componentsEntries = result.tracks.components; + + const render = blockingEntries.find((e) => e.type === 'render'); + const commit = blockingEntries.find((e) => e.type === 'commit'); + const button = componentsEntries.find((e) => e.name === 'Button'); + + // Render: start=2, dur=2, end=4. Commit: start=3, shifted to 4. + assert.equal(render.start, 2); + assert.equal(render.duration, 2); + assert.equal(commit.start, 4); + assert.equal(render.color, undefined); + assert.equal(commit.color, undefined); + // Button raw ts=330 is within Commit's raw range [320, 470), so it shifts to 4 + assert.equal(button.start, 4); + assert.equal(button.type, 'render'); + assert.equal(button.color, undefined); + }); + + it('returns empty tracks for registered tracks with no entries', () => { + const result = normalizeEntries( + [], + [{track: 'Suspense', trackGroup: 'Scheduler'}] + ); + assert.equal(result.duration, 0); + assert.ok(result.tracks.scheduler); + assert.deepEqual(result.tracks.scheduler.suspense, []); + }); +}); diff --git a/src/components/MDX/MDXComponents.tsx b/src/components/MDX/MDXComponents.tsx index a32dad27174..0b731ec76f3 100644 --- a/src/components/MDX/MDXComponents.tsx +++ b/src/components/MDX/MDXComponents.tsx @@ -25,6 +25,7 @@ import Intro from './Intro'; import BlogCard from './BlogCard'; import Link from './Link'; import {PackageImport} from './PackageImport'; +import {PerformanceTracks} from './PerformanceTracks'; import Recap from './Recap'; import Sandpack from './Sandpack'; import SandpackWithHTMLOutput from './SandpackWithHTMLOutput'; @@ -547,6 +548,7 @@ export const MDXComponents = { RSC, RSCBadge, PackageImport, + PerformanceTracks, ReadBlogPost, Recap, Recipes, diff --git a/src/components/MDX/PerformanceTracks.tsx b/src/components/MDX/PerformanceTracks.tsx new file mode 100644 index 00000000000..5c52f378e42 --- /dev/null +++ b/src/components/MDX/PerformanceTracks.tsx @@ -0,0 +1,730 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {useEffect, useMemo, useRef, useState} from 'react'; + +type DevToolsColor = + | 'primary' + | 'primary-light' + | 'primary-dark' + | 'secondary' + | 'secondary-light' + | 'secondary-dark' + | 'tertiary' + | 'tertiary-light' + | 'tertiary-dark' + | 'warning' + | 'error'; + +interface TrackEntry { + type: string; + name?: string; + start: number; + duration: number; + depth?: number; + perf?: 1 | 2 | 3; + annotation?: string; +} + +interface InternalEntry { + name: string; + start: number; + duration: number; + color: DevToolsColor; + depth?: number; + annotation?: string; +} + +interface InternalTrack { + name: string; + trackGroup?: string; + entries: InternalEntry[]; +} + +type TrackGroupValue = TrackEntry[] | Record; + +interface PerformanceTracksData { + duration: number; + tracks: Record; +} + +interface NormalizedData { + duration: number; + tracks: InternalTrack[]; + annotations: AnnotationTarget[]; +} + +interface AnnotationTarget { + text: string; + /** Time midpoint of the target entry */ + timeMid: number; + /** Index of the track (within the flat tracks array) this entry belongs to */ + trackIndex: number; +} + +const TYPE_LABELS: Record = { + event: 'Event', + update: 'Update', + render: 'Render', + commit: 'Commit', + waiting: 'Waiting', + action: 'Action', + suspended: 'Suspended', + prewarm: 'Prewarm', + 'remaining-effects': 'Remaining Effects', + mount: 'Mount', + unmount: 'Unmount', + effect: 'Effect', +}; + +const SCHEDULER_TYPE_COLORS: Record = { + event: 'warning', + update: 'primary-light', + render: 'primary-dark', + commit: 'secondary-dark', + waiting: 'secondary-light', + action: 'primary-dark', + suspended: 'primary-dark', + prewarm: 'primary-dark', + 'remaining-effects': 'secondary-dark', +}; + +function resolveComponentColor(entry: TrackEntry): DevToolsColor { + switch (entry.type) { + case 'render': { + const p = entry.perf ?? 1; + if (p <= 1) return 'primary-light'; + if (p === 2) return 'primary'; + return 'primary-dark'; + } + case 'mount': + case 'unmount': + return 'warning'; + case 'effect': { + const p = entry.perf ?? 1; + if (p <= 1) return 'secondary-light'; + if (p === 2) return 'secondary'; + return 'secondary-dark'; + } + default: + return 'primary'; + } +} + +function resolveDisplayName(entry: TrackEntry): string { + if (entry.type === 'event' && entry.name) return `Event: ${entry.name}`; + return entry.name ?? TYPE_LABELS[entry.type] ?? entry.type; +} + +function resolveEntry( + entry: TrackEntry, + isComponentTrack: boolean +): InternalEntry { + return { + name: resolveDisplayName(entry), + start: entry.start, + duration: entry.duration, + color: isComponentTrack + ? resolveComponentColor(entry) + : SCHEDULER_TYPE_COLORS[entry.type] ?? 'primary', + depth: entry.depth, + annotation: entry.annotation, + }; +} + +function normalizeData(input: PerformanceTracksData): NormalizedData { + const tracks: InternalTrack[] = []; + const annotations: AnnotationTarget[] = []; + for (const [groupId, value] of Object.entries(input.tracks)) { + const isComponentTrack = groupId === 'components'; + if (Array.isArray(value)) { + if (value.length === 0) continue; + const resolved = value.map((e) => resolveEntry(e, isComponentTrack)); + const trackIndex = tracks.length; + tracks.push({name: getLabel(groupId), entries: resolved}); + for (const entry of resolved) { + if (entry.annotation) { + annotations.push({ + text: entry.annotation, + timeMid: entry.start + entry.duration * 0.85, + trackIndex, + }); + } + } + } else { + const groupLabel = getLabel(groupId); + for (const [trackId, entries] of Object.entries(value)) { + if (entries.length === 0) continue; + const resolved = entries.map((e) => resolveEntry(e, false)); + const trackIndex = tracks.length; + tracks.push({ + name: getLabel(trackId), + trackGroup: groupLabel, + entries: resolved, + }); + for (const entry of resolved) { + if (entry.annotation) { + annotations.push({ + text: entry.annotation, + timeMid: entry.start + entry.duration * 0.85, + trackIndex, + }); + } + } + } + } + } + return {duration: input.duration, tracks, annotations}; +} + +// Color mapping from DevTools extension palette to hex values +// Same colors used in both light and dark mode to match Chrome DevTools appearance +const COLORS: Record = { + primary: {bg: '#149ECA', text: '#FFFFFF'}, + 'primary-light': {bg: '#ABE2ED', text: '#045975'}, + 'primary-dark': {bg: '#087EA4', text: '#FFFFFF'}, + secondary: {bg: '#6B75DB', text: '#FFFFFF'}, + 'secondary-light': {bg: '#C3C8F5', text: '#2B3491'}, + 'secondary-dark': {bg: '#575FB7', text: '#FFFFFF'}, + tertiary: {bg: '#44AC99', text: '#FFFFFF'}, + 'tertiary-light': {bg: '#ABDED5', text: '#2B6E62'}, + 'tertiary-dark': {bg: '#388F7F', text: '#FFFFFF'}, + warning: {bg: '#FABD62', text: '#23272F'}, + error: {bg: '#C1554D', text: '#FFFFFF'}, +}; + +const LABELS: Record = { + scheduler: 'Scheduler \u269B', + components: 'Components \u269B', + blocking: 'Blocking', + transition: 'Transition', + suspense: 'Suspense', + idle: 'Idle', +}; + +function getLabel(id: string): string { + return LABELS[id] ?? id; +} + +const ENTRY_HEIGHT = 18; +const ENTRY_GAP = 2; +const ROW_PADDING = 2; +const CHAR_WIDTH = 7; +const LABEL_PAD = 10; +const MAX_LABEL_PX = 130; +const GAP_CAP_PX = 40; +const TRACK_LABEL_WIDTH = 128; // matches w-32 +const CONTAINER_ESTIMATE = 500; + +const ANNOTATION_FONT_SIZE = 14; +const ANNOTATION_LINE_HEIGHT = 18; +const ANNOTATION_ARROW_GAP = 4; +const ANNOTATION_PADDING = 4; +const ANNOTATION_LABEL_HEIGHT = + ANNOTATION_LINE_HEIGHT + ANNOTATION_ARROW_GAP + ANNOTATION_PADDING; +const GROUP_HEADER_HEIGHT = 26; // includes 1px border-bottom + +interface LayoutResult { + totalWidth: number; + mapTime: (t: number) => number; + needsScroll: boolean; +} + +function computeLayout( + data: NormalizedData, + containerWidth: number +): LayoutResult { + const {duration} = data; + if (duration === 0) { + return {totalWidth: containerWidth, mapTime: () => 0, needsScroll: false}; + } + + // 1. Collect all unique breakpoints + const bpSet = new Set(); + bpSet.add(0); + bpSet.add(duration); + for (const track of data.tracks) { + for (const entry of track.entries) { + bpSet.add(entry.start); + bpSet.add(entry.start + entry.duration); + } + } + const breakpoints = Array.from(bpSet).sort((a, b) => a - b); + + // 2. Create segments between consecutive breakpoints + const segments: Array<{ + start: number; + end: number; + naturalPx: number; + allocatedPx: number; + }> = []; + for (let i = 0; i < breakpoints.length - 1; i++) { + const segStart = breakpoints[i]; + const segEnd = breakpoints[i + 1]; + const segDur = segEnd - segStart; + const naturalPx = (segDur / duration) * containerWidth; + segments.push({ + start: segStart, + end: segEnd, + naturalPx, + allocatedPx: naturalPx, + }); + } + + // 3. Collect all non-zero-duration entries with their minimum pixel needs + const entries: Array<{ + start: number; + end: number; + minPx: number; + duration: number; + }> = []; + for (const track of data.tracks) { + for (const entry of track.entries) { + if (entry.duration > 0) { + const minPx = Math.min( + entry.name.length * CHAR_WIDTH + LABEL_PAD, + MAX_LABEL_PX + ); + entries.push({ + start: entry.start, + end: entry.start + entry.duration, + minPx, + duration: entry.duration, + }); + } + } + } + + // 4. Compute minimum widths for each segment based on spanning entries + for (const entry of entries) { + // Find segments this entry spans + for (const seg of segments) { + if (seg.start >= entry.start && seg.end <= entry.end) { + // This segment is within the entry + const segDur = seg.end - seg.start; + const proportionalMin = entry.minPx * (segDur / entry.duration); + seg.allocatedPx = Math.max(seg.allocatedPx, proportionalMin); + } + } + } + + // 5. Compress gaps — segments that weren't stretched, respecting label minimums + for (const seg of segments) { + const wasStretched = seg.allocatedPx > seg.naturalPx + 0.01; + if (!wasStretched && seg.allocatedPx > GAP_CAP_PX) { + // Find minimum required by any spanning entry + let minRequired = 0; + for (const entry of entries) { + if (seg.start >= entry.start && seg.end <= entry.end) { + const segDur = seg.end - seg.start; + const proportionalMin = entry.minPx * (segDur / entry.duration); + minRequired = Math.max(minRequired, proportionalMin); + } + } + const logCompressed = + GAP_CAP_PX * (1 + Math.log2(seg.naturalPx / GAP_CAP_PX)); + seg.allocatedPx = Math.max(logCompressed, minRequired); + } + } + + // 6. Compute total and decide scroll vs scale + let totalAllocated = 0; + for (const seg of segments) { + totalAllocated += seg.allocatedPx; + } + + let needsScroll = false; + if (totalAllocated <= containerWidth) { + // Scale up proportionally to fill container + const scale = containerWidth / totalAllocated; + for (const seg of segments) { + seg.allocatedPx *= scale; + } + totalAllocated = containerWidth; + } else { + needsScroll = true; + } + + // 7. Build cumulative position map + const positions: Array<{time: number; px: number}> = []; + let cumPx = 0; + for (let i = 0; i < segments.length; i++) { + positions.push({time: segments[i].start, px: cumPx}); + cumPx += segments[i].allocatedPx; + } + positions.push({ + time: segments[segments.length - 1].end, + px: cumPx, + }); + + const totalWidth = cumPx; + + // mapTime: interpolate within segments + function mapTime(t: number): number { + if (t <= 0) return 0; + if (t >= duration) return totalWidth; + + // Binary search for the segment containing t + let lo = 0; + let hi = positions.length - 2; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (positions[mid].time <= t) { + lo = mid; + } else { + hi = mid - 1; + } + } + + const segStart = positions[lo]; + const segEnd = positions[lo + 1]; + const segDur = segEnd.time - segStart.time; + if (segDur === 0) return segStart.px; + + const frac = (t - segStart.time) / segDur; + return segStart.px + frac * (segEnd.px - segStart.px); + } + + return {totalWidth, mapTime, needsScroll}; +} + +function getTrackContentHeight(track: InternalTrack): number { + const maxDepth = + track.entries.length > 0 + ? Math.max(...track.entries.map((e) => e.depth || 0)) + : 0; + return (maxDepth + 1) * (ENTRY_HEIGHT + ENTRY_GAP) - ENTRY_GAP; +} + +function getTrackRowHeight(track: InternalTrack): number { + return getTrackContentHeight(track) + ROW_PADDING * 2 + 1; // +1 for border-bottom +} + +function EntryBar({ + entry, + layout, + roundLeft, + roundRight, + sameColorRight, +}: { + entry: InternalEntry; + layout: LayoutResult; + roundLeft: boolean; + roundRight: boolean; + sameColorRight: boolean; +}) { + const {mapTime} = layout; + const leftPx = mapTime(entry.start); + const rightPx = mapTime(entry.start + entry.duration); + const widthPx = rightPx - leftPx - (sameColorRight ? 1 : 0); + const topPx = (entry.depth || 0) * (ENTRY_HEIGHT + ENTRY_GAP); + const colors = COLORS[entry.color]; + + // Zero-duration markers: render as thin vertical line + if (entry.duration === 0) { + return ( +
+ ); + } + + const rounding = `${roundLeft ? 'rounded-l-sm' : ''} ${ + roundRight ? 'rounded-r-sm' : '' + }`; + + return ( +
+ + {entry.name} + +
+ ); +} + +function TrackRow({ + track, + layout, +}: { + track: InternalTrack; + layout: LayoutResult; +}) { + const contentHeight = getTrackContentHeight(track); + + return ( +
+
+ + {track.name} +
+
+ {track.entries.map((entry, i) => { + const depth = entry.depth || 0; + const end = entry.start + entry.duration; + const rightNeighbor = track.entries.find( + (other) => (other.depth || 0) === depth && other.start === end + ); + const roundLeft = !track.entries.some( + (other) => + (other.depth || 0) === depth && + other.start + other.duration === entry.start + ); + const roundRight = !rightNeighbor; + const sameColorRight = + !roundRight && rightNeighbor!.color === entry.color; + return ( + + ); + })} +
+
+ ); +} + +function TrackGroupSection({ + groupName, + tracks, + layout, +}: { + groupName: string; + tracks: InternalTrack[]; + layout: LayoutResult; +}) { + return ( +
+
+
+ + {groupName} +
+
+ {tracks.map((track, i) => ( + + ))} +
+ ); +} + +const ANNOTATION_COLOR = '#E05BD2'; +const ANNOTATION_ARCH_PX = 12; + +function AnnotationOverlay({ + annotations, + layout, + trackYOffsets, + tracks, + totalHeight, +}: { + annotations: AnnotationTarget[]; + layout: LayoutResult; + trackYOffsets: number[]; + tracks: InternalTrack[]; + totalHeight: number; +}) { + return ( + + {annotations.map((ann, i) => { + const xPx = layout.mapTime(ann.timeMid); + const labelY = ANNOTATION_PADDING; + const trackRow = tracks[ann.trackIndex]; + const trackRowMidY = + ANNOTATION_LABEL_HEIGHT + + trackYOffsets[ann.trackIndex] + + getTrackRowHeight(trackRow) / 2; + const stemStartY = + labelY + ANNOTATION_LINE_HEIGHT + ANNOTATION_ARROW_GAP; + const arrowTipY = trackRowMidY; + + // Label is offset to the right; curve starts near left edge of text + const startX = xPx + ANNOTATION_ARCH_PX; + const labelXPx = startX - CHAR_WIDTH; + const endX = xPx; + + // Cubic bezier: start straight down, sweep in the middle, arrive at 45° + const midY = (stemStartY + arrowTipY) / 2; + const cp1X = startX; + const cp1Y = midY; + const cp2X = endX + ANNOTATION_ARCH_PX; + const cp2Y = midY; + + // Tangent at t=1 of cubic bezier = end - cp2 (arrives at 45°) + const tx = endX - cp2X; + const ty = arrowTipY - cp2Y; + const angleDeg = (Math.atan2(ty, tx) * 180) / Math.PI - 90; + + return ( + + + {ann.text} + + + + + ); + })} + + ); +} + +export function PerformanceTracks({data}: {data: PerformanceTracksData}) { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(CONTAINER_ESTIMATE); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const measure = () => { + const w = el.clientWidth - TRACK_LABEL_WIDTH; + if (w > 0) setContainerWidth(w); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const normalizedData = useMemo(() => normalizeData(data), [data]); + const hasAnnotations = normalizedData.annotations.length > 0; + + const layout = useMemo( + () => computeLayout(normalizedData, containerWidth), + [normalizedData, containerWidth] + ); + + // Group tracks by trackGroup + const groups: Array<{groupName: string; tracks: InternalTrack[]}> = []; + const seen = new Map(); + + for (const track of normalizedData.tracks) { + const groupName = track.trackGroup || track.name; + const idx = seen.get(groupName); + if (idx !== undefined) { + groups[idx].tracks.push(track); + } else { + seen.set(groupName, groups.length); + groups.push({groupName, tracks: [track]}); + } + } + + // Compute Y offset of each track row (relative to the start of the tracks area) + const trackYOffsets: number[] = []; + let yOffset = 0; + const seenGroupsForY = new Set(); + for (const track of normalizedData.tracks) { + const groupName = track.trackGroup || track.name; + if (!seenGroupsForY.has(groupName)) { + seenGroupsForY.add(groupName); + yOffset += GROUP_HEADER_HEIGHT; + } + trackYOffsets.push(yOffset); + yOffset += getTrackRowHeight(track); + } + + return ( +
+
+ {hasAnnotations && ( + <> +
+ + + )} + {groups.map((group, i) => ( + + ))} +
+
+ ); +} diff --git a/src/content/reference/react/useOptimistic.md b/src/content/reference/react/useOptimistic.md index 702f9936ce9..5cada28cd67 100644 --- a/src/content/reference/react/useOptimistic.md +++ b/src/content/reference/react/useOptimistic.md @@ -114,6 +114,121 @@ This state is called the "optimistic" because it is used to immediately present There's no extra render to "clear" the optimistic state. The optimistic and real state converge in the same render when the Transition completes. +Here's what it looks like in the [React Performance Tracks](/reference/dev-tools/react-performance-tracks) + + + #### Optimistic state is temporary {/*optimistic-state-is-temporary*/}