From 97e4de9f99a8b845b6e87ba922b9c3d8bba87a78 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 28 May 2026 10:17:42 +0200 Subject: [PATCH 1/8] perf: memoize bubble styles of MessageContent --- .../MessageItemView/MessageContent.tsx | 65 +++++++++---------- 1 file changed, 30 insertions(+), 35 deletions(-) diff --git a/package/src/components/Message/MessageItemView/MessageContent.tsx b/package/src/components/Message/MessageItemView/MessageContent.tsx index d81ef24253..2ed01c9c25 100644 --- a/package/src/components/Message/MessageItemView/MessageContent.tsx +++ b/package/src/components/Message/MessageItemView/MessageContent.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { AnimatableNumericValue, ColorValue, Pressable, StyleSheet, View } from 'react-native'; +import { ColorValue, Pressable, StyleSheet, View, ViewStyle } from 'react-native'; import { MessageTextContainer } from './MessageTextContainer'; @@ -169,47 +169,46 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { [message, isMessageAIGenerated], ); - const getBorderRadius = () => { + // Merged background-color + border-radius object passed directly into the + // bubble's style array (no spread at the call site). Theme-defined radii + // override the group-position-computed defaults; theme-undefined radii are + // omitted so they don't override the computed defaults. + const bubbleColorAndRadius = useMemo(() => { // enum('top', 'middle', 'bottom', 'single') const groupPosition = groupStyles?.[0]; - const isBottomOrSingle = groupPosition === 'single' || groupPosition === 'bottom'; - let borderBottomLeftRadius = components.messageBubbleRadiusGroupBottom; - let borderBottomRightRadius = components.messageBubbleRadiusGroupBottom; + let computedBottomLeftRadius = components.messageBubbleRadiusGroupBottom; + let computedBottomRightRadius = components.messageBubbleRadiusGroupBottom; if (isBottomOrSingle) { - // add relevant sharp corner + // add relevant sharp corner (the "tail") if (isMyMessage) { - borderBottomRightRadius = components.messageBubbleRadiusTail; + computedBottomRightRadius = components.messageBubbleRadiusTail; } else { - borderBottomLeftRadius = components.messageBubbleRadiusTail; + computedBottomLeftRadius = components.messageBubbleRadiusTail; } } - return { - borderBottomLeftRadius, - borderBottomRightRadius, - }; - }; - - const getBorderRadiusFromTheme = () => { - const bordersFromTheme: Record = { - borderBottomLeftRadius, - borderBottomRightRadius, - borderRadius, - borderTopLeftRadius, - borderTopRightRadius, + const style: ViewStyle = { + backgroundColor, + borderBottomLeftRadius: borderBottomLeftRadius ?? computedBottomLeftRadius, + borderBottomRightRadius: borderBottomRightRadius ?? computedBottomRightRadius, }; + if (borderRadius !== undefined) style.borderRadius = borderRadius; + if (borderTopLeftRadius !== undefined) style.borderTopLeftRadius = borderTopLeftRadius; + if (borderTopRightRadius !== undefined) style.borderTopRightRadius = borderTopRightRadius; - // filter out undefined values - for (const key in bordersFromTheme) { - if (bordersFromTheme[key] === undefined) { - delete bordersFromTheme[key]; - } - } - - return bordersFromTheme; - }; + return style; + }, [ + backgroundColor, + borderBottomLeftRadius, + borderBottomRightRadius, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + groupStyles, + isMyMessage, + ]); const { setNativeScrollability } = useMessageListItemContext(); const hasContentSideViews = !!(MessageContentLeadingView || MessageContentTrailingView); @@ -357,11 +356,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { Date: Thu, 28 May 2026 10:43:14 +0200 Subject: [PATCH 2/8] perf: remove redundant empty object --- .../components/Message/MessageItemView/MessageContent.tsx | 3 ++- .../Thread/__tests__/__snapshots__/Thread.test.tsx.snap | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package/src/components/Message/MessageItemView/MessageContent.tsx b/package/src/components/Message/MessageItemView/MessageContent.tsx index 2ed01c9c25..ce29f28020 100644 --- a/package/src/components/Message/MessageItemView/MessageContent.tsx +++ b/package/src/components/Message/MessageItemView/MessageContent.tsx @@ -357,7 +357,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { style={[ styles.containerInner, bubbleColorAndRadius, - noBorder ? { borderWidth: 0 } : {}, + noBorder ? styles.noBorder : null, containerInner, messageGroupedSingleOrBottom ? isVeryLastMessage && enableMessageGroupingByUser @@ -679,6 +679,7 @@ const styles = StyleSheet.create({ alignSelf: 'center', }, galleryContainer: {}, + noBorder: { borderWidth: 0 }, rightAlignContent: { justifyContent: 'flex-end', }, diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap index 3b4aa75c01..68d9536de2 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.tsx.snap @@ -511,7 +511,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderBottomLeftRadius": 0, "borderBottomRightRadius": 20, }, - {}, + null, {}, {}, ] @@ -843,7 +843,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderBottomLeftRadius": 0, "borderBottomRightRadius": 20, }, - {}, + null, {}, {}, ] @@ -1208,7 +1208,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderBottomLeftRadius": 0, "borderBottomRightRadius": 20, }, - {}, + null, {}, {}, ] @@ -1534,7 +1534,7 @@ exports[`Thread should match thread snapshot 1`] = ` "borderBottomLeftRadius": 0, "borderBottomRightRadius": 20, }, - {}, + null, {}, {}, ] From 80ec32e95c9c4bbea95315843eb749c6237a3c4e Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 28 May 2026 14:56:11 +0200 Subject: [PATCH 3/8] perf: performance cli wip --- .../Message/MessageItemView/MessageBubble.tsx | 4 +- .../MessageItemView/MessageWrapper.tsx | 2 +- .../useCreateMessageContext.bench.ts | 174 ++++++ .../Message/hooks/useCreateMessageContext.ts | 45 +- perf/.gitignore | 2 + perf/README.md | 48 ++ perf/analyze-cpuprofile.js | 499 ++++++++++++++++++ perf/analyze-react-profile.js | 370 +++++++++++++ perf/capture-hermes-profile.js | 271 ++++++++++ 9 files changed, 1399 insertions(+), 16 deletions(-) create mode 100644 package/src/components/Message/hooks/__tests__/useCreateMessageContext.bench.ts create mode 100644 perf/.gitignore create mode 100644 perf/README.md create mode 100644 perf/analyze-cpuprofile.js create mode 100644 perf/analyze-react-profile.js create mode 100644 perf/capture-hermes-profile.js diff --git a/package/src/components/Message/MessageItemView/MessageBubble.tsx b/package/src/components/Message/MessageItemView/MessageBubble.tsx index 3277850cfc..5fa759b494 100644 --- a/package/src/components/Message/MessageItemView/MessageBubble.tsx +++ b/package/src/components/Message/MessageItemView/MessageBubble.tsx @@ -27,7 +27,9 @@ type SwipableMessageWrapperProps = Pick< onSwipe: () => void; }; -export const SwipableMessageWrapper = React.memo((props: SwipableMessageWrapperProps) => { +export const SwipableMessageWrapper = React.memo(function SwipableMessageWrapper( + props: SwipableMessageWrapperProps, +) { const { children, messageSwipeToReplyHitSlop, onSwipe } = props; const { MessageSwipeContent } = useComponentsContext(); const isRTL = I18nManager.isRTL; diff --git a/package/src/components/Message/MessageItemView/MessageWrapper.tsx b/package/src/components/Message/MessageItemView/MessageWrapper.tsx index 3f93169281..2a86922a19 100644 --- a/package/src/components/Message/MessageItemView/MessageWrapper.tsx +++ b/package/src/components/Message/MessageItemView/MessageWrapper.tsx @@ -30,7 +30,7 @@ export type MessageWrapperProps = { nextMessage?: LocalMessage; }; -export const MessageWrapper = React.memo((props: MessageWrapperProps) => { +export const MessageWrapper = React.memo(function MessageWrapper(props: MessageWrapperProps) { const { message, previousMessage, nextMessage } = props; const { client } = useChatContext(); const { diff --git a/package/src/components/Message/hooks/__tests__/useCreateMessageContext.bench.ts b/package/src/components/Message/hooks/__tests__/useCreateMessageContext.bench.ts new file mode 100644 index 0000000000..db24a4f7b0 --- /dev/null +++ b/package/src/components/Message/hooks/__tests__/useCreateMessageContext.bench.ts @@ -0,0 +1,174 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Microbenchmark for Step 8 — measures the actual cost of the eager + * stringifications in `useCreateMessageContext` against the proposed + * Option B (ref/primitive deps replacing the stringifies). + * + * Run: cd package && TZ=UTC npx jest useCreateMessageContext.bench + * + * NOTE: Numbers are from Node V8 (jest), not Hermes-on-Android. The + * relative magnitudes carry; absolute numbers will be ~2-5x slower + * on a mid-range Android device. Use this for "which is bigger" and + * "how big roughly," not for production budget claims. + * + * `as any` casts are intentional — fixtures intentionally widen types to + * pack heavy reaction/i18n payloads that the strict mock-builder types + * would otherwise reject. + */ +import { generateMember } from '../../../../mock-builders/generator/member'; +import { generateMessage } from '../../../../mock-builders/generator/message'; +import { generateReaction } from '../../../../mock-builders/generator/reaction'; +import { generateUser } from '../../../../mock-builders/generator/user'; +import { stringifyMessage } from '../../../../utils/utils'; + +// --- Fixtures ------------------------------------------------------------- + +const shortPlainMessage = generateMessage({ text: 'hello' }); + +const longTextMessage = generateMessage({ + text: 'a'.repeat(500), + i18n: { + en_text: 'a'.repeat(500), + fr_text: 'b'.repeat(500), + language: 'en', + } as any, +}); + +const heavyReactionsMessage = generateMessage({ + text: 'message with reactions', + latest_reactions: Array.from({ length: 20 }, (_, i) => + generateReaction({ type: ['like', 'love', 'haha', 'wow'][i % 4], user_id: `u${i}` } as any), + ) as any, + reaction_groups: { + like: { count: 8, sum_scores: 8, first_reaction_at: new Date(), last_reaction_at: new Date() }, + love: { count: 6, sum_scores: 6, first_reaction_at: new Date(), last_reaction_at: new Date() }, + haha: { count: 4, sum_scores: 4, first_reaction_at: new Date(), last_reaction_at: new Date() }, + wow: { count: 2, sum_scores: 2, first_reaction_at: new Date(), last_reaction_at: new Date() }, + } as any, +}); + +const quotedMessage = generateMessage({ + text: 'a quoted reply', + quoted_message: generateMessage({ text: 'the original message being quoted' }) as any, +}); + +const members50 = Object.fromEntries( + Array.from({ length: 50 }, () => { + const user = generateUser(); + return [user.id, generateMember({ user })]; + }), +); + +// --- Bench harness -------------------------------------------------------- + +const ITERATIONS = 100_000; +const WARMUP = 5_000; + +function bench(label: string, fn: () => unknown) { + // warmup + for (let i = 0; i < WARMUP; i++) fn(); + // measure + const start = process.hrtime.bigint(); + for (let i = 0; i < ITERATIONS; i++) fn(); + const end = process.hrtime.bigint(); + const totalNs = Number(end - start); + const perCallNs = totalNs / ITERATIONS; + const perCallUs = perCallNs / 1000; + console.log( + ` ${label.padEnd(60)} ${perCallUs.toFixed(3).padStart(8)} µs/call (${(totalNs / 1_000_000).toFixed(1)} ms total / ${ITERATIONS.toLocaleString()} iters)`, + ); + return perCallUs; +} + +describe('Step 8 microbenchmark', () => { + // Don't fail the test on time variance — this is observational, not assertive. + jest.setTimeout(120_000); + + it('measures the four eager stringifications in useCreateMessageContext', () => { + console.log('\n=== CURRENT BEHAVIOR (eager stringifications per call) ===\n'); + + console.log('-- stringifyMessage --'); + const sShort = bench('short plain message', () => + stringifyMessage({ message: shortPlainMessage }), + ); + const sLong = bench('long-text message (500 chars + i18n)', () => + stringifyMessage({ message: longTextMessage }), + ); + const sHeavy = bench('message with 20 reactions + 4 reaction groups', () => + stringifyMessage({ message: heavyReactionsMessage }), + ); + const sQuoted = bench('quoted-message stringify (includeReactions: false)', () => + stringifyMessage({ message: quotedMessage.quoted_message as any, includeReactions: false }), + ); + + console.log('\n-- members stringify --'); + const m50 = bench('JSON.stringify(members) — 50 members', () => JSON.stringify(members50)); + + console.log('\n-- reactions stringify (the local `reactionsValue` line) --'); + const reactions20 = heavyReactionsMessage.latest_reactions as any[]; + const reactionsEmpty: any[] = []; + const r20 = bench('reactions.map().join() — 20 reactions', () => + reactions20.map(({ count, own, type }: any) => `${own}${type}${count}`).join(), + ); + const rEmpty = bench('reactions.map().join() — 0 reactions', () => + reactionsEmpty.map(({ count, own, type }: any) => `${own}${type}${count}`).join(), + ); + + console.log('\n=== TOTAL per useCreateMessageContext call (realistic mix) ===\n'); + const totalShort = sShort + m50 + rEmpty; + const totalLong = sLong + m50 + rEmpty; + const totalHeavy = sHeavy + m50 + r20 + sQuoted; + bench(`(reference) short plain msg + 50 members, no reactions`, () => {}); + console.log(` summed cost: ${totalShort.toFixed(3)} µs/call`); + console.log(` long text : ${totalLong.toFixed(3)} µs/call`); + console.log(` heavy msg : ${totalHeavy.toFixed(3)} µs/call`); + + console.log( + '\n=== OPTION B EQUIVALENT (deps-only — Object.is checks on refs/primitives) ===\n', + ); + + // Simulating "the cost of having the deps array compare 25 entries" — what + // useMemo does internally on a hit/miss check. + const prevDeps: any[] = [ + true, // actionsEnabled + 'left', // alignment + () => {}, // goToMessage + [], // stableGroupStyles + false, // hasAttachmentActions + false, // hasReactions + false, // messageHasOnlySingleAttachment + false, // lastGroupMessage + '{}', // myMessageThemeString + 'overlay-id', // messageOverlayId + [], // readBy + 0, // deliveredToCount + true, // showAvatar + true, // showMessageStatus + false, // threadList + false, // preventPress + () => {}, // unregisterMessageOverlayTarget + members50, // members + heavyReactionsMessage.type, // message.type + heavyReactionsMessage.deleted_at, // message.deleted_at + heavyReactionsMessage.text, // message.text + heavyReactionsMessage.reply_count, // message.reply_count + heavyReactionsMessage.status, // message.status + heavyReactionsMessage.updated_at, // message.updated_at + heavyReactionsMessage.i18n, // message.i18n + heavyReactionsMessage.attachments, // message.attachments + heavyReactionsMessage.latest_reactions, // message.latest_reactions + heavyReactionsMessage.reaction_groups, // message.reaction_groups + ]; + const nextDeps = [...prevDeps]; // simulate "memo hit": all refs identical + bench(`28-dep array compare via Object.is (memo hit, all refs equal)`, () => { + // Exactly what areHookInputsEqual does in React internals. + for (let i = 0; i < prevDeps.length; i++) { + if (!Object.is(prevDeps[i], nextDeps[i])) break; + } + }); + + // Verifies the bench compiled & returns truthy results so jest doesn't fail + expect(sShort).toBeGreaterThan(0); + expect(m50).toBeGreaterThan(0); + }); +}); diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index 3c2141699e..6afeb82431 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -2,8 +2,6 @@ import { useMemo, useRef } from 'react'; import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; -import { stringifyMessage } from '../../../utils/utils'; - function useStableRefValue(value: T): T { const ref = useRef(value); @@ -60,16 +58,8 @@ export const useCreateMessageContext = ({ setQuotedMessage, }: MessageContextValue) => { const stableGroupStyles = useStableRefValue(groupStyles); - const reactionsValue = reactions.map(({ count, own, type }) => `${own}${type}${count}`).join(); - const stringifiedMessage = stringifyMessage({ message }); - - const membersValue = JSON.stringify(members); const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); - const stringifiedQuotedMessage = message.quoted_message - ? stringifyMessage({ includeReactions: false, message: message.quoted_message }) - : ''; - const messageContext: MessageContextValue = useMemo( () => ({ actionsEnabled, @@ -126,12 +116,39 @@ export const useCreateMessageContext = ({ hasReactions, messageHasOnlySingleAttachment, lastGroupMessage, - membersValue, + // `members` ref is stable in steady-state (high-frequency events like + // message.new / message.read / typing.* don't trigger the SDK's + // copyStateFromChannel shallow-spread). When it does change, the + // outer `Message.areEqual` `Object.keys.length` guard already + // filters inner-member updates, so ref-equality here matches the + // existing observable semantics. + members, myMessageThemeString, messageOverlayId, - reactionsValue, - stringifiedMessage, - stringifiedQuotedMessage, + // Replaces `stringifiedMessage` + `reactionsValue`: stream-chat-js + // `_updateMessage` always replaces the Message object (and these + // nested fields with it) rather than mutating in place, so + // ref-equality on the fields equals content-equality. + message.type, + message.deleted_at, + message.text, + message.reply_count, + message.status, + message.updated_at, + message.i18n, + message.attachments, + message.latest_reactions, + message.reaction_groups, + // Replaces `stringifiedQuotedMessage` — matches the + // `stringifyMessage({ includeReactions: false })` field list. + message.quoted_message?.type, + message.quoted_message?.deleted_at, + message.quoted_message?.text, + message.quoted_message?.reply_count, + message.quoted_message?.status, + message.quoted_message?.updated_at, + message.quoted_message?.i18n, + message.quoted_message?.attachments, readBy, deliveredToCount, showAvatar, diff --git a/perf/.gitignore b/perf/.gitignore new file mode 100644 index 0000000000..0427870a07 --- /dev/null +++ b/perf/.gitignore @@ -0,0 +1,2 @@ +profiles/ +*.cpuprofile diff --git a/perf/README.md b/perf/README.md new file mode 100644 index 0000000000..e42f3de830 --- /dev/null +++ b/perf/README.md @@ -0,0 +1,48 @@ +# perf/ + +Profiling tooling for the SDK row-render perf initiative. + +## Capture a `.cpuprofile` + +1. Run SampleApp on a device (iOS or Android — Hermes either way). Make sure Metro is up. +2. Open a Chromium-based browser → `chrome://inspect` → click **inspect** on the Hermes target. +3. In DevTools: **Performance** tab → **Record** (Cmd+E). +4. Do the scenario (open a channel with 30+ messages; optionally scroll a bit to trigger more renders/recycles). +5. **Stop** recording. +6. Right-click the recording → **Save profile…** → save into `perf/profiles/` (gitignored). + +A 10–15 second profile is plenty for analysis. + +## Analyze a single profile + +```sh +node perf/analyze-cpuprofile.js perf/profiles/baseline.cpuprofile +``` + +Outputs: + +- Profile summary (duration, sample count, sample rate). +- Time by **category** (markdown / stringify / stream-chat-js / react internals / app code / etc.). +- Time by **source file**. +- **Top functions by self time** (where the JS thread actually sits). +- **Top functions by total time** (which call sites dominate). +- Focused breakdowns: time inside `MessageWithContext`, `useCreateMessageContext`, `renderText`, `stringifyMessage` (no-ops if a function isn't in the profile). + +## Diff two profiles (before vs after a change) + +```sh +node perf/analyze-cpuprofile.js --diff perf/profiles/before.cpuprofile perf/profiles/after.cpuprofile +``` + +Outputs: + +- Per-category self-time delta. +- Top function self-time deltas (sorted by `|delta|`). + +For a fair diff, capture both profiles using the **same scenario** and the **same device** in roughly the same conditions. + +## Conventions + +- Keep captured `.cpuprofile` files in `perf/profiles/` (gitignored). +- For diff comparisons, name them descriptively: `baseline.cpuprofile`, `step-8.cpuprofile`, `step-12.cpuprofile`, etc. +- Profiles MUST be captured in dev mode (Metro) so function names are intact. Release builds are minified and the analyzer output becomes useless. diff --git a/perf/analyze-cpuprofile.js b/perf/analyze-cpuprofile.js new file mode 100644 index 0000000000..e34e6bdf2b --- /dev/null +++ b/perf/analyze-cpuprofile.js @@ -0,0 +1,499 @@ +#!/usr/bin/env node +/** + * Analyze a Hermes / V8 sampling profile (.cpuprofile) and report what's + * clogging the JS thread. Also supports diffing two profiles. + * + * Usage: + * node perf/analyze-cpuprofile.js + * node perf/analyze-cpuprofile.js --diff + * + * The .cpuprofile format is the standard Chrome DevTools sampling profile + * (same JSON shape whether captured from V8 or Hermes). No native deps. + */ + +const fs = require('fs'); +const path = require('path'); + +// ---------- helpers ------------------------------------------------------ + +function microsToMs(us) { + return (us / 1000).toFixed(1); +} + +function pct(num, denom) { + if (!denom) return '0.00'; + return ((num / denom) * 100).toFixed(2); +} + +function pad(s, n, right = false) { + s = String(s); + if (s.length >= n) return s; + return right ? s + ' '.repeat(n - s.length) : ' '.repeat(n - s.length) + s; +} + +function nodeLabel(node) { + const cf = node.callFrame || {}; + const fn = cf.functionName || '(anonymous)'; + const url = cf.url || ''; + if (!url) return fn; + // Last 2 path segments + line number + const parts = url.split('/').filter(Boolean); + const filename = parts.slice(-2).join('/'); + const line = typeof cf.lineNumber === 'number' ? cf.lineNumber + 1 : '?'; + return `${fn} (${filename}:${line})`; +} + +function shortFile(node) { + const url = (node.callFrame && node.callFrame.url) || ''; + if (!url) return ''; + return url.split('/').slice(-2).join('/'); +} + +// ---------- profile loader / preprocessor -------------------------------- + +function loadProfile(filePath) { + if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + process.exit(1); + } + let raw; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } catch (err) { + console.error(`Failed to read ${filePath}: ${err.message}`); + process.exit(1); + } + let profile; + try { + profile = JSON.parse(raw); + } catch (err) { + console.error(`Not valid JSON: ${filePath} (${err.message})`); + process.exit(1); + } + // The file may be one of: + // 1) A V8 .cpuprofile object: { nodes, samples, timeDeltas, ... } + // 2) A Chrome trace event array (RN 0.81 Bridgeless `Tracing` domain): + // [{name:"Profile", args:{data:{startTime}}}, {name:"ProfileChunk", args:{data:{cpuProfile:{nodes,samples}, timeDeltas}}}, ...] + // Convert (2) to (1) before returning. + if (Array.isArray(profile)) { + profile = chromeTraceToV8Profile(profile, filePath); + } + if (!profile.nodes || !profile.samples) { + console.error(`File does not look like a .cpuprofile (missing nodes/samples): ${filePath}`); + process.exit(1); + } + return profile; +} + +// Convert a Chrome trace event array to a V8 .cpuprofile object. +// Hermes/RN Tracing nodes use `parent: `; V8 .cpuprofile uses `children: [...]`. +// We accumulate nodes/samples/timeDeltas across all ProfileChunk events and +// derive `children` from `parent` references. +function chromeTraceToV8Profile(events, filePath) { + const profileEvent = events.find((e) => e.name === 'Profile'); + const chunkEvents = events.filter((e) => e.name === 'ProfileChunk'); + if (!profileEvent && chunkEvents.length === 0) { + console.error(`Trace file has no Profile/ProfileChunk events: ${filePath}`); + process.exit(1); + } + const startTime = profileEvent?.args?.data?.startTime || 0; + const nodesById = new Map(); + const samples = []; + const timeDeltas = []; + for (const c of chunkEvents) { + const d = c.args?.data || {}; + const chunkNodes = d.cpuProfile?.nodes || []; + const chunkSamples = d.cpuProfile?.samples || []; + const chunkDeltas = d.timeDeltas || []; + for (const n of chunkNodes) { + if (!nodesById.has(n.id)) { + // Hermes callFrame uses scriptId as number; V8 expects string. Normalize. + const cf = { ...(n.callFrame || {}) }; + if (typeof cf.scriptId === 'number') cf.scriptId = String(cf.scriptId); + nodesById.set(n.id, { + id: n.id, + callFrame: cf, + parent: n.parent || null, + hitCount: 0, + children: [], + }); + } + } + for (const s of chunkSamples) samples.push(s); + for (const dt of chunkDeltas) timeDeltas.push(dt); + } + // Derive children lists from parent refs + for (const node of nodesById.values()) { + if (node.parent != null) { + const p = nodesById.get(node.parent); + if (p) p.children.push(node.id); + } + } + // Compute endTime from accumulated deltas + const totalUs = timeDeltas.reduce((a, b) => a + b, 0); + return { + nodes: [...nodesById.values()], + samples, + timeDeltas, + startTime, + endTime: startTime + totalUs, + }; +} + +function buildIndex(profile) { + const nodesById = new Map(); + for (const node of profile.nodes) { + nodesById.set(node.id, { + ...node, + parent: null, + selfTimeUs: 0, + totalTimeUs: 0, + }); + } + for (const node of nodesById.values()) { + if (!node.children) continue; + for (const childId of node.children) { + const child = nodesById.get(childId); + if (child) child.parent = node.id; + } + } + return nodesById; +} + +function computeSelfTimes(profile, nodesById) { + const samples = profile.samples; + const deltas = profile.timeDeltas || []; + for (let i = 0; i < samples.length; i++) { + const nodeId = samples[i]; + const delta = deltas[i] || 0; + const node = nodesById.get(nodeId); + if (node) node.selfTimeUs += delta; + } +} + +function computeTotalTimes(nodesById) { + // Memoized DFS — each tree is independent so no double-counting risk. + const memo = new Map(); + function totalOf(node) { + if (memo.has(node.id)) return memo.get(node.id); + let t = node.selfTimeUs; + if (node.children) { + for (const childId of node.children) { + const child = nodesById.get(childId); + if (child) t += totalOf(child); + } + } + memo.set(node.id, t); + return t; + } + for (const node of nodesById.values()) { + node.totalTimeUs = totalOf(node); + } +} + +// ---------- categorization ------------------------------------------------ + +function categorize(node) { + const cf = node.callFrame || {}; + const fn = cf.functionName || ''; + const url = cf.url || ''; + + // Hermes / V8 placeholder frames + if (fn === '(root)' || fn === '(program)' || fn === '(idle)') return 'IDLE/ROOT'; + if (fn === '(garbage collector)' || fn === '(gc)' || /^gc\b/i.test(fn)) return 'GC'; + + if (/react-native-markdown|markdown-it|SimpleMarkdown/i.test(url)) return 'MARKDOWN'; + if (/SimpleMarkdown|markdown/i.test(fn)) return 'MARKDOWN'; + + if ( + fn === 'JSON.stringify' || + fn === 'JSON.parse' || + fn === 'stringifyMessage' || + fn === 'reduceMessagesToString' + ) + return 'STRINGIFY/JSON'; + + if (/stream-chat\/(src|dist)/.test(url) && !/stream-chat-react-native/.test(url)) + return 'STREAM_CHAT_JS'; + + if (/\/(react|react-dom|scheduler)\/(cjs|umd)/.test(url) || /Reconciler/i.test(url)) + return 'REACT_INTERNALS'; + + if (/react-native\/Libraries/.test(url)) return 'RN_INTERNALS'; + + if ( + /stream-chat-react-native\/(src|lib)/.test(url) || + /package\/src\//.test(url) || + /\/src\//.test(url) + ) + return 'APP_CODE (SDK)'; + + if (/node_modules\/react-native-/.test(url)) return 'RN_3P_LIBS'; + + if (!url) return 'NATIVE/OTHER'; + return 'OTHER'; +} + +// ---------- printers ------------------------------------------------------ + +function printSummary(profile, label = '') { + const totalDurationUs = profile.endTime - profile.startTime; + console.log(`\n${label ? '[' + label + '] ' : ''}Profile: ${profile.sourceFile || '(unknown)'}`); + console.log( + ` Duration: ${microsToMs(totalDurationUs)} ms Samples: ${profile.samples.length} ` + + `Sample rate: ${(profile.samples.length / (totalDurationUs / 1_000_000)).toFixed(0)}/s`, + ); + return totalDurationUs; +} + +function aggregateBy(nodesById, fn) { + // Aggregate self time by an arbitrary keyer. + const agg = new Map(); + for (const node of nodesById.values()) { + if (!node.selfTimeUs) continue; + const key = fn(node); + agg.set(key, (agg.get(key) || 0) + node.selfTimeUs); + } + return agg; +} + +function topN(agg, n) { + return Array.from(agg.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, n); +} + +function printTopBySelf(nodesById, totalDurationUs, limit = 25) { + const sorted = Array.from(nodesById.values()) + .filter((n) => n.selfTimeUs > 0) + .sort((a, b) => b.selfTimeUs - a.selfTimeUs) + .slice(0, limit); + + console.log('\n=== Top functions by SELF time ==='); + console.log(` ${pad('Self%', 6)} ${pad('Self ms', 8)} Function`); + for (const n of sorted) { + console.log( + ` ${pad(pct(n.selfTimeUs, totalDurationUs) + '%', 6)} ${pad(microsToMs(n.selfTimeUs) + 'ms', 8)} ${nodeLabel(n)}`, + ); + } +} + +function printTopByTotal(nodesById, totalDurationUs, limit = 25) { + const sorted = Array.from(nodesById.values()) + .filter((n) => n.totalTimeUs > 0) + .sort((a, b) => b.totalTimeUs - a.totalTimeUs) + .slice(0, limit); + + console.log('\n=== Top functions by TOTAL time (incl. callees) ==='); + console.log(` ${pad('Total%', 6)} ${pad('Total ms', 8)} Function`); + for (const n of sorted) { + console.log( + ` ${pad(pct(n.totalTimeUs, totalDurationUs) + '%', 6)} ${pad(microsToMs(n.totalTimeUs) + 'ms', 8)} ${nodeLabel(n)}`, + ); + } +} + +function printByCategory(nodesById, totalDurationUs) { + const byCat = aggregateBy(nodesById, categorize); + console.log('\n=== Time by category (self time, all frames) ==='); + console.log(` ${pad('Self%', 6)} ${pad('Self ms', 8)} Category`); + for (const [cat, us] of topN(byCat, 30)) { + console.log( + ` ${pad(pct(us, totalDurationUs) + '%', 6)} ${pad(microsToMs(us) + 'ms', 8)} ${cat}`, + ); + } +} + +function printByFile(nodesById, totalDurationUs, limit = 25) { + const byFile = aggregateBy(nodesById, shortFile); + console.log('\n=== Time by source file (self time) ==='); + console.log(` ${pad('Self%', 6)} ${pad('Self ms', 8)} File`); + for (const [file, us] of topN(byFile, limit)) { + console.log( + ` ${pad(pct(us, totalDurationUs) + '%', 6)} ${pad(microsToMs(us) + 'ms', 8)} ${file}`, + ); + } +} + +function findNodesByFunctionName(nodesById, fnName) { + return Array.from(nodesById.values()).filter( + (n) => (n.callFrame && n.callFrame.functionName) === fnName, + ); +} + +function collectDescendantsBySelf(nodesById, rootNodeId, agg = new Map(), seen = new Set()) { + if (seen.has(rootNodeId)) return agg; + seen.add(rootNodeId); + const node = nodesById.get(rootNodeId); + if (!node) return agg; + if (node.selfTimeUs) { + const key = nodeLabel(node); + agg.set(key, (agg.get(key) || 0) + node.selfTimeUs); + } + if (node.children) { + for (const childId of node.children) { + collectDescendantsBySelf(nodesById, childId, agg, seen); + } + } + return agg; +} + +function printInside(nodesById, fnName, totalDurationUs) { + const matches = findNodesByFunctionName(nodesById, fnName); + if (matches.length === 0) { + console.log(`\n=== Time inside ${fnName}: NOT FOUND in profile ===`); + return; + } + let totalInside = 0; + const aggregated = new Map(); + for (const m of matches) { + totalInside += m.totalTimeUs; + collectDescendantsBySelf(nodesById, m.id, aggregated); + } + console.log( + `\n=== Time inside ${fnName} (${matches.length} call frame${matches.length === 1 ? '' : 's'}, ${microsToMs(totalInside)}ms total = ${pct(totalInside, totalDurationUs)}% of profile) ===`, + ); + const sorted = Array.from(aggregated.entries()) + .filter(([_, us]) => us > 0) + .sort((a, b) => b[1] - a[1]) + .slice(0, 25); + console.log(` ${pad('Self%', 7)} ${pad('Self ms', 8)} Function`); + for (const [label, us] of sorted) { + console.log( + ` ${pad(pct(us, totalInside) + '%', 7)} ${pad(microsToMs(us) + 'ms', 8)} ${label}`, + ); + } +} + +// ---------- single-file mode -------------------------------------------- + +function analyzeSingle(filePath) { + const profile = loadProfile(filePath); + profile.sourceFile = path.basename(filePath); + const totalDurationUs = printSummary(profile); + const nodesById = buildIndex(profile); + computeSelfTimes(profile, nodesById); + computeTotalTimes(nodesById); + + printByCategory(nodesById, totalDurationUs); + printByFile(nodesById, totalDurationUs); + printTopBySelf(nodesById, totalDurationUs); + printTopByTotal(nodesById, totalDurationUs); + + // Focused breakdowns for the surfaces we care about. + // These are no-ops if the function isn't in the profile (e.g., minified or never called). + printInside(nodesById, 'MessageWithContext', totalDurationUs); + printInside(nodesById, 'useCreateMessageContext', totalDurationUs); + printInside(nodesById, 'renderText', totalDurationUs); + printInside(nodesById, 'stringifyMessage', totalDurationUs); +} + +// ---------- diff mode ---------------------------------------------------- + +function buildAgg(filePath) { + const profile = loadProfile(filePath); + const nodesById = buildIndex(profile); + computeSelfTimes(profile, nodesById); + computeTotalTimes(nodesById); + + const totalDurationUs = profile.endTime - profile.startTime; + const byFn = aggregateBy(nodesById, nodeLabel); + const byCat = aggregateBy(nodesById, categorize); + const byFile = aggregateBy(nodesById, shortFile); + return { profile, totalDurationUs, byFn, byCat, byFile }; +} + +function printCategoryDiff(beforeAgg, afterAgg) { + const keys = new Set([...beforeAgg.byCat.keys(), ...afterAgg.byCat.keys()]); + const rows = []; + for (const k of keys) { + const b = beforeAgg.byCat.get(k) || 0; + const a = afterAgg.byCat.get(k) || 0; + rows.push({ + cat: k, + before: b, + after: a, + delta: a - b, + beforePct: pct(b, beforeAgg.totalDurationUs), + afterPct: pct(a, afterAgg.totalDurationUs), + }); + } + rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)); + console.log('\n=== Category diff (self time) — sorted by abs delta ==='); + console.log( + ` ${pad('Before ms', 10)} ${pad('After ms', 10)} ${pad('Delta ms', 10)} ${pad('Before %', 9)} ${pad('After %', 9)} Category`, + ); + for (const r of rows) { + const arrow = r.delta < 0 ? '↓' : r.delta > 0 ? '↑' : '·'; + console.log( + ` ${pad(microsToMs(r.before), 10)} ${pad(microsToMs(r.after), 10)} ${pad((r.delta >= 0 ? '+' : '') + microsToMs(r.delta), 10)} ${pad(r.beforePct + '%', 9)} ${pad(r.afterPct + '%', 9)} ${arrow} ${r.cat}`, + ); + } +} + +function printFunctionDiff(beforeAgg, afterAgg, limit = 25) { + const keys = new Set([...beforeAgg.byFn.keys(), ...afterAgg.byFn.keys()]); + const rows = []; + for (const k of keys) { + const b = beforeAgg.byFn.get(k) || 0; + const a = afterAgg.byFn.get(k) || 0; + if (b === 0 && a === 0) continue; + rows.push({ fn: k, before: b, after: a, delta: a - b }); + } + rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)); + console.log(`\n=== Top function deltas (self time, top ${limit} by |delta|) ===`); + console.log( + ` ${pad('Before ms', 10)} ${pad('After ms', 10)} ${pad('Delta ms', 10)} Function`, + ); + for (const r of rows.slice(0, limit)) { + const arrow = r.delta < 0 ? '↓' : r.delta > 0 ? '↑' : '·'; + console.log( + ` ${pad(microsToMs(r.before), 10)} ${pad(microsToMs(r.after), 10)} ${pad((r.delta >= 0 ? '+' : '') + microsToMs(r.delta), 10)} ${arrow} ${r.fn}`, + ); + } +} + +function analyzeDiff(beforePath, afterPath) { + console.log( + `Diffing:\n before: ${path.basename(beforePath)}\n after: ${path.basename(afterPath)}`, + ); + const beforeAgg = buildAgg(beforePath); + const afterAgg = buildAgg(afterPath); + console.log( + `\nDurations — before: ${microsToMs(beforeAgg.totalDurationUs)}ms / after: ${microsToMs(afterAgg.totalDurationUs)}ms`, + ); + console.log( + `Samples — before: ${beforeAgg.profile.samples.length} / after: ${afterAgg.profile.samples.length}`, + ); + console.log( + `Note: durations should be similar for a fair comparison. Large differences mean the scenario timing varied; interpret with care.`, + ); + + printCategoryDiff(beforeAgg, afterAgg); + printFunctionDiff(beforeAgg, afterAgg); +} + +// ---------- main --------------------------------------------------------- + +function main() { + const args = process.argv.slice(2); + if (args.length === 0 || args[0] === '-h' || args[0] === '--help') { + console.log(` +Usage: + node perf/analyze-cpuprofile.js + node perf/analyze-cpuprofile.js --diff +`); + process.exit(args.length === 0 ? 1 : 0); + } + if (args[0] === '--diff') { + if (args.length !== 3) { + console.error('--diff requires exactly two .cpuprofile paths'); + process.exit(1); + } + analyzeDiff(args[1], args[2]); + } else { + analyzeSingle(args[0]); + } +} + +main(); diff --git a/perf/analyze-react-profile.js b/perf/analyze-react-profile.js new file mode 100644 index 0000000000..268f18c179 --- /dev/null +++ b/perf/analyze-react-profile.js @@ -0,0 +1,370 @@ +#!/usr/bin/env node + +/** + * Analyze a React DevTools Profiler export (the JSON you get from "Save + * profile" in the Profiler tab — NOT a Hermes .cpuprofile; that's a + * different format handled by analyze-cpuprofile.js). + * + * Usage: + * node perf/analyze-react-profile.js path/to/profile.json + * + * Output: top components by total self time, by total actual time, by avg + * self time per render; slowest single commits; what triggered each commit; + * focused breakdown for Message-row components. All numbers in ms. + * + * Implementation notes: + * - `snapshots` only contains the fiber tree at the START of the profile. + * Fibers that mount DURING the profile (the common case for scroll work) + * have their displayName encoded into the per-commit `operations` stream + * as length-prefixed UTF-8 entries. We decode that stream so message + * rows etc. get real names, not ``. + * - Operation codes (React DevTools constants): + * 1 = TREE_OPERATION_ADD + * 2 = TREE_OPERATION_REMOVE + * 3 = TREE_OPERATION_REORDER_CHILDREN + * 4 = TREE_OPERATION_UPDATE_TREE_BASE_DURATION + * 5 = TREE_OPERATION_REMOVE_ROOT + * 6 = TREE_OPERATION_SET_SUBTREE_MODE + */ + +const fs = require('fs'); +const path = require('path'); + +const profilePath = process.argv[2]; +if (!profilePath) { + console.error('usage: node perf/analyze-react-profile.js '); + process.exit(1); +} + +const data = JSON.parse(fs.readFileSync(profilePath, 'utf8')); +const root = data.dataForRoots[0]; + +// ---- fiberId -> displayName from snapshots (initial tree) --------------- +const fiberInfo = new Map(); +for (const [id, info] of root.snapshots) { + fiberInfo.set(id, info); +} + +// ---- Decode operations to learn names of fibers that mount during profile + +// op codes +const OP_ADD = 1; +const OP_REMOVE = 2; +const OP_REORDER = 3; +const OP_UPDATE_BASE = 4; +const OP_REMOVE_ROOT = 5; +const OP_SET_SUBTREE_MODE = 6; + +function decodeOperations(ops) { + // ops[0] = rendererID, ops[1] = rootID, ops[2] = stringTableLen + const stringTableLen = ops[2]; + let i = 3; + // Build a 1-indexed string table for this commit + const strings = [null]; // index 0 means "null/no string" + const tableEnd = i + stringTableLen; + while (i < tableEnd) { + const len = ops[i++]; + // bytes follow as UTF-8 code points + const bytes = ops.slice(i, i + len); + i += len; + strings.push(Buffer.from(bytes).toString('utf8')); + } + // Now consume operation entries until end of array + while (i < ops.length) { + const code = ops[i++]; + switch (code) { + case OP_ADD: { + const id = ops[i++]; + const type = ops[i++]; + if (type === 11) { + // Root: parentID(0), isStrictMode, supportsProfiling, supportsStrictMode, hasOwnerMetadata + i += 5; + if (!fiberInfo.has(id)) fiberInfo.set(id, { id, type, displayName: 'Root' }); + } else { + const parentID = ops[i++]; + const ownerID = ops[i++]; + const displayNameStringID = ops[i++]; + const keyStringID = ops[i++]; + const displayName = strings[displayNameStringID] || null; + const key = strings[keyStringID] || null; + if (!fiberInfo.has(id)) { + fiberInfo.set(id, { id, type, parentID, ownerID, displayName, key }); + } else { + // Refresh in case snapshots had it with no displayName + const existing = fiberInfo.get(id); + if (!existing.displayName && displayName) existing.displayName = displayName; + } + } + break; + } + case OP_REMOVE: { + const n = ops[i++]; + i += n; + break; + } + case OP_REORDER: { + i++; // id + const n = ops[i++]; + i += n; + break; + } + case OP_UPDATE_BASE: { + i += 2; + break; + } + case OP_REMOVE_ROOT: { + // no args + break; + } + case OP_SET_SUBTREE_MODE: { + i += 2; + break; + } + default: { + // unknown — bail out of this commit to avoid corrupting future reads + return; + } + } + } +} + +for (const ops of root.operations) { + try { + decodeOperations(ops); + } catch (e) { + // tolerate one malformed commit; keep going + } +} + +const nameOf = (id) => { + const info = fiberInfo.get(id); + if (!info) return ``; + return info.displayName || ``; +}; + +// ---- Per-name aggregation ------------------------------------------------ +const perName = new Map(); +function bump(name, fiberId, selfMs, actualMs) { + let e = perName.get(name); + if (!e) { + e = { selfMs: 0, actualMs: 0, renderCount: 0, fiberIds: new Set(), maxSelf: 0, maxActual: 0 }; + perName.set(name, e); + } + e.selfMs += selfMs; + e.actualMs += actualMs; + e.renderCount += 1; + e.fiberIds.add(fiberId); + if (selfMs > e.maxSelf) e.maxSelf = selfMs; + if (actualMs > e.maxActual) e.maxActual = actualMs; +} + +const commits = []; +let totalCommitDuration = 0; +let earliestTs = Infinity; +let latestTs = -Infinity; + +for (const commit of root.commitData) { + const selfMap = new Map(commit.fiberSelfDurations || []); + const actualMap = new Map(commit.fiberActualDurations || []); + const ids = new Set([...selfMap.keys(), ...actualMap.keys()]); + let commitSelf = 0; + const commitContributors = []; + for (const id of ids) { + const name = nameOf(id); + const selfMs = selfMap.get(id) ?? 0; + const actualMs = actualMap.get(id) ?? 0; + bump(name, id, selfMs, actualMs); + commitSelf += selfMs; + commitContributors.push({ name, id, selfMs, actualMs }); + } + commits.push({ + duration: commit.duration, + effectDuration: commit.effectDuration, + passiveEffectDuration: commit.passiveEffectDuration, + priorityLevel: commit.priorityLevel, + timestamp: commit.timestamp, + commitSelf, + contributors: commitContributors, + updaters: commit.updaters || [], + }); + totalCommitDuration += commit.duration || 0; + if (commit.timestamp < earliestTs) earliestTs = commit.timestamp; + if (commit.timestamp > latestTs) latestTs = commit.timestamp; +} + +// ---- Output -------------------------------------------------------------- +const pad = (s, n) => String(s).padEnd(n); +const padR = (s, n) => String(s).padStart(n); +const fmt = (ms) => `${ms.toFixed(2)}ms`; + +console.log(`\n=== Profile summary ===`); +console.log(`File: ${path.resolve(profilePath)}`); +console.log(`Root: ${root.displayName} (rootID=${root.rootID})`); +console.log(`Commits: ${commits.length}`); +console.log(`Wall-time span: ${(latestTs - earliestTs).toFixed(0)} ms`); +console.log(`Sum of commit durations: ${totalCommitDuration.toFixed(1)} ms`); +console.log(`Avg commit duration: ${(totalCommitDuration / commits.length).toFixed(2)} ms`); +console.log( + `Resolved fiber names: ${[...fiberInfo.values()].filter((f) => f.displayName).length} / ${fiberInfo.size}`, +); + +const allEntries = [...perName.entries()].map(([name, e]) => ({ name, ...e })); + +console.log(`\n=== Top 40 components by total SELF time ===`); +console.log( + `${pad('Component', 50)} ${padR('SelfMs', 10)} ${padR('Renders', 8)} ${padR('Avg', 8)} ${padR('Max', 8)}`, +); +allEntries + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 40) + .forEach((e) => { + console.log( + `${pad(e.name.slice(0, 50), 50)} ${padR(e.selfMs.toFixed(2), 10)} ${padR(e.renderCount, 8)} ${padR((e.selfMs / e.renderCount).toFixed(3), 8)} ${padR(e.maxSelf.toFixed(2), 8)}`, + ); + }); + +console.log(`\n=== Top 25 components by total ACTUAL time (incl. children) ===`); +console.log( + `${pad('Component', 50)} ${padR('ActualMs', 10)} ${padR('Renders', 8)} ${padR('Avg', 8)} ${padR('Max', 8)}`, +); +allEntries + .sort((a, b) => b.actualMs - a.actualMs) + .slice(0, 25) + .forEach((e) => { + console.log( + `${pad(e.name.slice(0, 50), 50)} ${padR(e.actualMs.toFixed(2), 10)} ${padR(e.renderCount, 8)} ${padR((e.actualMs / e.renderCount).toFixed(3), 8)} ${padR(e.maxActual.toFixed(2), 8)}`, + ); + }); + +console.log(`\n=== Top 25 by AVERAGE self time per render (min 3 renders) ===`); +console.log(`${pad('Component', 50)} ${padR('Avg', 10)} ${padR('Max', 10)} ${padR('Renders', 8)}`); +allEntries + .filter((e) => e.renderCount >= 3) + .sort((a, b) => b.selfMs / b.renderCount - a.selfMs / a.renderCount) + .slice(0, 25) + .forEach((e) => { + console.log( + `${pad(e.name.slice(0, 50), 50)} ${padR((e.selfMs / e.renderCount).toFixed(3), 10)} ${padR(e.maxSelf.toFixed(2), 10)} ${padR(e.renderCount, 8)}`, + ); + }); + +console.log(`\n=== Top 25 single-render hits across all commits ===`); +console.log(`${pad('Component', 50)} ${padR('SelfMs', 10)} ${padR('ActualMs', 10)} (commit ts)`); +const allHits = []; +for (const c of commits) { + for (const x of c.contributors) { + allHits.push({ ...x, ts: c.timestamp }); + } +} +allHits + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 25) + .forEach((h) => { + console.log( + `${pad(h.name.slice(0, 50), 50)} ${padR(h.selfMs.toFixed(2), 10)} ${padR(h.actualMs.toFixed(2), 10)} (t=${h.ts.toFixed(0)})`, + ); + }); + +console.log(`\n=== Top 12 SLOWEST commits ===`); +commits + .slice() + .sort((a, b) => (b.duration || 0) - (a.duration || 0)) + .slice(0, 12) + .forEach((c, i) => { + const top = c.contributors + .slice() + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 6) + .map((x) => `${x.name}@${x.selfMs.toFixed(1)}`) + .join(', '); + const updaters = (c.updaters || []) + .map((u) => u.displayName || `<${u.id}>`) + .slice(0, 4) + .join(', '); + console.log( + `${i + 1}. duration=${fmt(c.duration || 0)} (self=${fmt(c.commitSelf)}, ${c.contributors.length} fibers, prio=${c.priorityLevel}, t=${c.timestamp.toFixed(0)})`, + ); + console.log(` updaters: ${updaters || '(none recorded)'}`); + console.log(` top self: ${top}`); + }); + +console.log(`\n=== Top 20 UPDATERS by # of commits triggered ===`); +const updaterCount = new Map(); +for (const c of commits) { + for (const u of c.updaters || []) { + const k = u.displayName || ``; + updaterCount.set(k, (updaterCount.get(k) || 0) + 1); + } +} +[...updaterCount.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .forEach(([name, n]) => { + console.log(`${pad(name.slice(0, 60), 60)} ${padR(n, 6)}`); + }); + +// ---- Focused: Message subtree ------------------------------------------- +const messageNamesRe = + /^(Message|MessageWithContext|MemoizedMessage|MessageItemView|MessageItemViewWithContext|MemoizedMessageItemView|MessageContent|MessageContentWithContext|MemoizedMessageContent|MessageFooter|MessageTextContainer|MessageBubble|SwipableMessageWrapper|MessageWrapper|MessageAvatar|MessageAuthor|MessageStatus|MessageTimestamp|MessageReplies|MessageHeader|MessageList|MessageListWithContext|MessageFlashList|MessageFlashListWithContext|MessageSimple|MessageInput|MessageInputWithContext|TypingIndicator|ReactionList)$/; + +console.log(`\n=== Message-subtree focus (all matching components, by self time) ===`); +console.log( + `${pad('Component', 50)} ${padR('SelfMs', 10)} ${padR('ActualMs', 10)} ${padR('Renders', 8)} ${padR('Avg', 8)} ${padR('Max', 8)}`, +); +allEntries + .filter((e) => messageNamesRe.test(e.name)) + .sort((a, b) => b.selfMs - a.selfMs) + .forEach((e) => { + console.log( + `${pad(e.name.slice(0, 50), 50)} ${padR(e.selfMs.toFixed(2), 10)} ${padR(e.actualMs.toFixed(2), 10)} ${padR(e.renderCount, 8)} ${padR((e.selfMs / e.renderCount).toFixed(3), 8)} ${padR(e.maxSelf.toFixed(2), 8)}`, + ); + }); + +// ---- Heuristic buckets -------------------------------------------------- +const heuristics = [ + { label: 'Markdown', re: /Markdown|markdown|MDX|renderText|MDRender/i }, + { label: 'Reanimated', re: /Animated|Reanimated|SharedValue/i }, + { label: 'Gesture/RNGH', re: /Gesture|GestureDetector|GestureHandler/i }, + { label: 'FlatList/FlashList', re: /FlatList|FlashList|VirtualizedList|CellRenderer/i }, + { label: 'Image', re: /Image|FastImage|Gallery/i }, + { label: 'Pressable/Touchable', re: /^Pressable$|TouchableOpacity|TouchableHighlight/ }, + { label: 'Context.Provider', re: /Provider$/ }, + { label: 'Memo/ForwardRef wrappers', re: /^Memo|^ForwardRef/ }, + { label: 'Stream Channel*/Chat*/Thread*', re: /^(Channel|Chat|Thread)/ }, + { label: 'Stream Message*', re: /^Message/ }, + { label: 'Reactions', re: /Reaction/ }, + { label: 'Attachments', re: /Attachment|Audio|Video|File|Card/i }, + { label: 'Poll', re: /Poll/ }, + { label: 'Avatar / Author / Status', re: /Avatar|Author|Status/ }, +]; +console.log(`\n=== Heuristic buckets — total self time across bucket ===`); +console.log(`${pad('Bucket', 42)} ${padR('SelfMs', 8)} ${padR('Renders', 8)} top members`); +for (const h of heuristics) { + const hits = allEntries.filter((e) => h.re.test(e.name)); + const sumSelf = hits.reduce((s, x) => s + x.selfMs, 0); + const sumRenders = hits.reduce((s, x) => s + x.renderCount, 0); + const topNames = hits + .slice() + .sort((a, b) => b.selfMs - a.selfMs) + .slice(0, 6) + .map((x) => `${x.name}(${x.selfMs.toFixed(0)}ms,${x.renderCount}r)`) + .join(', '); + console.log( + `${pad(h.label, 42)} ${padR(sumSelf.toFixed(1), 8)} ${padR(sumRenders, 8)} ${topNames}`, + ); +} + +console.log(`\n=== Components rendered with the most distinct fiber instances ===`); +console.log( + `${pad('Component', 50)} ${padR('Fibers', 8)} ${padR('Renders', 8)} ${padR('SelfMs', 10)}`, +); +allEntries + .sort((a, b) => b.fiberIds.size - a.fiberIds.size) + .slice(0, 25) + .forEach((e) => { + console.log( + `${pad(e.name.slice(0, 50), 50)} ${padR(e.fiberIds.size, 8)} ${padR(e.renderCount, 8)} ${padR(e.selfMs.toFixed(2), 10)}`, + ); + }); + +console.log(`\n=== Done ===\n`); diff --git a/perf/capture-hermes-profile.js b/perf/capture-hermes-profile.js new file mode 100644 index 0000000000..1273d20946 --- /dev/null +++ b/perf/capture-hermes-profile.js @@ -0,0 +1,271 @@ +#!/usr/bin/env node + +/** + * Capture a Hermes CPU profile from a running React Native app via Metro's + * inspector WebSocket. Works on iOS and Android with no app code changes + * and no react-native-fs / native modules. + * + * Prereqs: + * 1. Metro running (e.g. `yarn workspace sampleapp start`). + * 2. SampleApp open on a device or simulator (Hermes is on by default in + * RN 0.70+). + * + * Usage: + * node perf/capture-hermes-profile.js [output-path] + * + * Flow: + * 1. Asks Metro for its list of debug targets at http://localhost:8081/json/list + * 2. Connects to the Hermes target's webSocketDebuggerUrl + * 3. Sends Profiler.enable + Profiler.start (Chrome DevTools Protocol) + * 4. Waits for you to press Enter + * 5. Sends Profiler.stop, receives the profile JSON + * 6. Writes to disk as a `.cpuprofile` + * + * Then: `node perf/analyze-cpuprofile.js ` + */ + +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +const readline = require('readline'); + +const OUT = + process.argv[2] || + path.join( + __dirname, + 'profiles', + `hermes-${new Date().toISOString().replace(/[:.]/g, '-')}.cpuprofile`, + ); + +const METRO_HOST = process.env.METRO_HOST || 'localhost'; +const METRO_PORT = process.env.METRO_PORT || '8081'; + +function httpGetJson(url) { + return new Promise((resolve, reject) => { + http + .get(url, (res) => { + let body = ''; + res.on('data', (d) => (body += d)); + res.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(e); + } + }); + }) + .on('error', reject); + }); +} + +function prompt(msg) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => + rl.question(msg, (ans) => { + rl.close(); + resolve(ans); + }), + ); +} + +function pickTarget(targets) { + // Prefer something that mentions Hermes / RNRuntime; fall back to first with a JS context. + const score = (t) => { + const s = `${t.title || ''} ${t.deviceName || ''} ${t.type || ''}`.toLowerCase(); + let n = 0; + if (s.includes('hermes')) n += 5; + if (s.includes('rnruntime') || s.includes('react native')) n += 3; + if (t.webSocketDebuggerUrl) n += 1; + return n; + }; + return targets.filter((t) => t.webSocketDebuggerUrl).sort((a, b) => score(b) - score(a))[0]; +} + +async function main() { + console.log(`Looking for Metro at http://${METRO_HOST}:${METRO_PORT} ...`); + let targets; + try { + targets = await httpGetJson(`http://${METRO_HOST}:${METRO_PORT}/json/list`); + } catch (e) { + console.error(`Could not reach Metro at ${METRO_HOST}:${METRO_PORT}.`); + console.error('Make sure `yarn workspace sampleapp start` is running and the app is open.'); + process.exit(1); + } + + if (!Array.isArray(targets) || targets.length === 0) { + console.error('Metro reported no debug targets. Is the app open on a device/simulator?'); + process.exit(1); + } + + const target = pickTarget(targets); + if (!target) { + console.error('No debug target had a webSocketDebuggerUrl. Targets:'); + console.error(JSON.stringify(targets, null, 2)); + process.exit(1); + } + + console.log(`Found target: ${target.title || '(no title)'} — ${target.deviceName || ''}`); + console.log(`Connecting: ${target.webSocketDebuggerUrl}`); + + // Node 22+ has global WebSocket; older Node would need `ws`. + if (typeof WebSocket === 'undefined') { + console.error( + 'Global WebSocket is not available — you are on Node < 22. Either upgrade Node or `yarn add -D ws` and let me know.', + ); + process.exit(1); + } + const ws = new WebSocket(target.webSocketDebuggerUrl); + + let msgId = 0; + const pending = new Map(); + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const id = ++msgId; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + }); + + ws.addEventListener('message', (evt) => { + let msg; + try { + msg = JSON.parse(evt.data); + } catch { + return; + } + if (msg.id && pending.has(msg.id)) { + const p = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) p.reject(new Error(msg.error.message)); + else p.resolve(msg.result); + } + }); + + await new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()); + ws.addEventListener('error', (e) => reject(new Error(`WS error: ${e.message || e}`))); + }); + + // RN 0.81 Bridgeless / Fusebox dropped the CDP `Profiler` domain. The + // working alternatives are (a) the Tracing domain, (b) calling + // HermesInternal.enableSamplingProfiler directly via Runtime.evaluate. + // We try (a) first — easier to parse — then fall back to (b). + + const tryProfilerCdp = async () => { + try { + await send('Profiler.enable'); + await send('Profiler.start'); + return 'profiler'; + } catch { + return null; + } + }; + + const tryTracing = async () => { + try { + // Chrome trace category that emits CPU samples + await send('Tracing.start', { + categories: + '-*,disabled-by-default-v8.cpu_profiler.hires,disabled-by-default-v8.cpu_profiler', + options: 'sampling-frequency=10000', + transferMode: 'ReturnAsStream', + }); + return 'tracing'; + } catch { + return null; + } + }; + + const tryHermesInternal = async () => { + const res = await send('Runtime.evaluate', { + expression: + 'typeof HermesInternal === "object" && typeof HermesInternal.enableSamplingProfiler === "function"', + returnByValue: true, + }); + if (res?.result?.value !== true) return null; + await send('Runtime.evaluate', { + expression: 'HermesInternal.enableSamplingProfiler(true)', + returnByValue: true, + }); + return 'hermes'; + }; + + console.log('Connected. Probing profiler support ...'); + let mode = await tryProfilerCdp(); + if (!mode) mode = await tryTracing(); + if (!mode) mode = await tryHermesInternal(); + if (!mode) { + console.error('None of Profiler / Tracing / HermesInternal worked on this target. Aborting.'); + process.exit(1); + } + console.log(`Using ${mode} mode.`); + + console.log('\n=== PROFILING. Do your scenario now (open channel, scroll, etc). ==='); + await prompt('Press Enter to STOP and save the profile: '); + + console.log('Stopping profile ...'); + let profileJson; + + if (mode === 'profiler') { + const result = await send('Profiler.stop'); + profileJson = result?.profile; + } else if (mode === 'tracing') { + const collected = []; + const onMessage = (evt) => { + const msg = JSON.parse(evt.data); + if (msg.method === 'Tracing.dataCollected') { + collected.push(...(msg.params?.value || [])); + } + }; + ws.addEventListener('message', onMessage); + await send('Tracing.end'); + // Wait briefly for Tracing.tracingComplete + dataCollected events + await new Promise((r) => setTimeout(r, 1500)); + profileJson = collected; + } else if (mode === 'hermes') { + // For HermesInternal mode we need an absolute path the app can write to. + // iOS Simulator inherits the host's /tmp — works directly. For an iOS + // device or Android device this path won't work; user will need to grab + // the file off-device. + const devicePath = `/tmp/hermes-${Date.now()}.cpuprofile`; + await send('Runtime.evaluate', { + expression: `HermesInternal.dumpSampledTraceToFile(${JSON.stringify(devicePath)})`, + returnByValue: true, + }); + await send('Runtime.evaluate', { + expression: 'HermesInternal.enableSamplingProfiler(false)', + returnByValue: true, + }); + console.log(`\nProfile written by the app to: ${devicePath}`); + console.log('If you are on iOS Simulator, that path is accessible directly on your Mac.'); + console.log( + 'If you are on an iOS device, you will need to pull it via Xcode Devices → app sandbox.', + ); + ws.close(); + process.exit(0); + } + + if (!profileJson) { + console.error('No profile returned. Mode:', mode); + process.exit(1); + } + + fs.mkdirSync(path.dirname(OUT), { recursive: true }); + fs.writeFileSync(OUT, JSON.stringify(profileJson)); + const stats = fs.statSync(OUT); + console.log(`\nSaved ${(stats.size / 1024).toFixed(1)} KB profile to:`); + console.log(` ${OUT}`); + + ws.close(); + + // Chain into the analyzer so the user doesn't have to copy-paste a path. + const { spawnSync } = require('child_process'); + const analyzer = path.join(__dirname, 'analyze-cpuprofile.js'); + console.log(`\nRunning analyzer:\n node ${analyzer} ${OUT}\n`); + const r = spawnSync(process.execPath, [analyzer, OUT], { stdio: 'inherit' }); + process.exit(r.status ?? 0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); From eb53d232867c6d5dee04813ff07927d56f4b6616 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 28 May 2026 14:57:12 +0200 Subject: [PATCH 4/8] fix: commit no dev config as well --- examples/SampleApp/metro.config.no-dev.js | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 examples/SampleApp/metro.config.no-dev.js diff --git a/examples/SampleApp/metro.config.no-dev.js b/examples/SampleApp/metro.config.no-dev.js new file mode 100644 index 0000000000..aaeff9b92b --- /dev/null +++ b/examples/SampleApp/metro.config.no-dev.js @@ -0,0 +1,43 @@ +/** + * Metro config that forces a `dev=false` bundle for performance profiling. + * + * Use this to measure scroll/render perf WITHOUT React's dev-mode wrappers + * (`runWithFiberInDEV`, `getComponentStack`, etc — they account for ~22% + * of a captured profile and don't exist in release builds). Bundle is still + * unminified so function names stay readable in the .cpuprofile. + * + * Usage: + * yarn workspace sampleapp start --config metro.config.no-dev.js --reset-cache + * + * Then reload the app (shake → Reload, or `r` in Metro). The next bundle + * fetch will be served with dev=false regardless of what the app asks for. + * Run `node perf/capture-hermes-profile.js` as usual. To restore normal + * dev mode just stop Metro and start it again without `--config`. + * + * NOTE: this only changes the served JS bundle. The native binary is still + * a debug build; native code paths (Yoga, layout, view creation, image + * decoding) remain debug-instrumented. To benchmark a true release native + * pipeline you'd need to build a release variant of the app itself. + */ +const baseConfig = require('./metro.config.js'); + +module.exports = { + ...baseConfig, + server: { + ...(baseConfig.server || {}), + enhanceMiddleware: (middleware, metroServer) => { + const wrapped = + baseConfig.server && typeof baseConfig.server.enhanceMiddleware === 'function' + ? baseConfig.server.enhanceMiddleware(middleware, metroServer) + : middleware; + return (req, res, next) => { + if (req.url && req.url.includes('dev=true')) { + req.url = req.url.replace(/([?&])dev=true/g, '$1dev=false'); + // Print once-per-request so it's obvious what's happening. + process.stdout.write(`[no-dev] rewrote bundle URL to: ${req.url}\n`); + } + return wrapped(req, res, next); + }; + }, + }, +}; From c20fef7af0e223b54e5dddacdb904f9a4be86d89 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 29 May 2026 19:05:49 +0200 Subject: [PATCH 5/8] fix: revert stringification removal --- .../Message/hooks/useCreateMessageContext.ts | 45 ++++++------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index 6afeb82431..3c2141699e 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -2,6 +2,8 @@ import { useMemo, useRef } from 'react'; import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; +import { stringifyMessage } from '../../../utils/utils'; + function useStableRefValue(value: T): T { const ref = useRef(value); @@ -58,8 +60,16 @@ export const useCreateMessageContext = ({ setQuotedMessage, }: MessageContextValue) => { const stableGroupStyles = useStableRefValue(groupStyles); + const reactionsValue = reactions.map(({ count, own, type }) => `${own}${type}${count}`).join(); + const stringifiedMessage = stringifyMessage({ message }); + + const membersValue = JSON.stringify(members); const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); + const stringifiedQuotedMessage = message.quoted_message + ? stringifyMessage({ includeReactions: false, message: message.quoted_message }) + : ''; + const messageContext: MessageContextValue = useMemo( () => ({ actionsEnabled, @@ -116,39 +126,12 @@ export const useCreateMessageContext = ({ hasReactions, messageHasOnlySingleAttachment, lastGroupMessage, - // `members` ref is stable in steady-state (high-frequency events like - // message.new / message.read / typing.* don't trigger the SDK's - // copyStateFromChannel shallow-spread). When it does change, the - // outer `Message.areEqual` `Object.keys.length` guard already - // filters inner-member updates, so ref-equality here matches the - // existing observable semantics. - members, + membersValue, myMessageThemeString, messageOverlayId, - // Replaces `stringifiedMessage` + `reactionsValue`: stream-chat-js - // `_updateMessage` always replaces the Message object (and these - // nested fields with it) rather than mutating in place, so - // ref-equality on the fields equals content-equality. - message.type, - message.deleted_at, - message.text, - message.reply_count, - message.status, - message.updated_at, - message.i18n, - message.attachments, - message.latest_reactions, - message.reaction_groups, - // Replaces `stringifiedQuotedMessage` — matches the - // `stringifyMessage({ includeReactions: false })` field list. - message.quoted_message?.type, - message.quoted_message?.deleted_at, - message.quoted_message?.text, - message.quoted_message?.reply_count, - message.quoted_message?.status, - message.quoted_message?.updated_at, - message.quoted_message?.i18n, - message.quoted_message?.attachments, + reactionsValue, + stringifiedMessage, + stringifiedQuotedMessage, readBy, deliveredToCount, showAvatar, From 526407c97d7e23123d1f351899a3ce55b01a36e4 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 29 May 2026 19:06:23 +0200 Subject: [PATCH 6/8] chore: advanced perf scripts --- perf/README.md | 53 ++++-- perf/analyze-cpuprofile.js | 199 +++++++++++++++++------ perf/capture-server.js | 270 +++++++++++++++++++++++++++++++ perf/desymbolicate-cpuprofile.js | 187 +++++++++++++++++++++ 4 files changed, 647 insertions(+), 62 deletions(-) create mode 100644 perf/capture-server.js create mode 100644 perf/desymbolicate-cpuprofile.js diff --git a/perf/README.md b/perf/README.md index e42f3de830..4ff80ef01a 100644 --- a/perf/README.md +++ b/perf/README.md @@ -4,11 +4,23 @@ Profiling tooling for the SDK row-render perf initiative. ## Capture a `.cpuprofile` -1. Run SampleApp on a device (iOS or Android — Hermes either way). Make sure Metro is up. -2. Open a Chromium-based browser → `chrome://inspect` → click **inspect** on the Hermes target. -3. In DevTools: **Performance** tab → **Record** (Cmd+E). -4. Do the scenario (open a channel with 30+ messages; optionally scroll a bit to trigger more renders/recycles). -5. **Stop** recording. +Two options. + +### A) Via the helper script + +```sh +node perf/capture-hermes-profile.js +``` + +Connects to Metro's Hermes target, starts profiling, waits for Enter, writes a `.cpuprofile` into `perf/profiles/`, then auto-runs the analyzer. + +### B) Via Chrome DevTools + +1. Run SampleApp on a device. Make sure Metro is up. +2. Chromium → `chrome://inspect` → click **inspect** on the Hermes target. +3. DevTools → **Performance** tab → **Record** (Cmd+E). +4. Do your scenario (open a channel with 30+ messages; scroll to trigger renders). +5. **Stop**. 6. Right-click the recording → **Save profile…** → save into `perf/profiles/` (gitignored). A 10–15 second profile is plenty for analysis. @@ -22,11 +34,29 @@ node perf/analyze-cpuprofile.js perf/profiles/baseline.cpuprofile Outputs: - Profile summary (duration, sample count, sample rate). -- Time by **category** (markdown / stringify / stream-chat-js / react internals / app code / etc.). +- ⚠ warning if the profile looks un-desymbolicated. +- **Time by category** — auto-bucketed by source: `Idle`, `GC`, `npm: `, `SDK source`, `App source`, `builtin:Object`, `builtin:JSON`, `VM / native`. No hand-curated patterns. - Time by **source file**. - **Top functions by self time** (where the JS thread actually sits). - **Top functions by total time** (which call sites dominate). -- Focused breakdowns: time inside `MessageWithContext`, `useCreateMessageContext`, `renderText`, `stringifyMessage` (no-ops if a function isn't in the profile). + +Optional drilldown into specific functions: + +```sh +node perf/analyze-cpuprofile.js perf/profiles/x.cpuprofile \ + --inside MessageWithContext,useCreateMessageContext,renderText +``` + +## Desymbolicate (per-package buckets) + +Dev profiles collapse every frame into one Metro bundle URL, so categorization shows everything as `App source`. To recover per-package attribution, fetch Metro's source map and run the desymbolicator: + +```sh +curl -s 'http://localhost:8081/index.map?platform=ios&dev=true&minify=false' \ + -o /tmp/dev.map.json +node perf/desymbolicate-cpuprofile.js perf/profiles/x.cpuprofile /tmp/dev.map.json +node perf/analyze-cpuprofile.js perf/profiles/x.desymbolicated.cpuprofile +``` ## Diff two profiles (before vs after a change) @@ -34,15 +64,12 @@ Outputs: node perf/analyze-cpuprofile.js --diff perf/profiles/before.cpuprofile perf/profiles/after.cpuprofile ``` -Outputs: - -- Per-category self-time delta. -- Top function self-time deltas (sorted by `|delta|`). +Per-category self-time delta + top function self-time deltas (sorted by `|delta|`). Optional `--grep ` to zoom in on specific function names. Warns if sample rates between the two profiles diverge >10%. For a fair diff, capture both profiles using the **same scenario** and the **same device** in roughly the same conditions. ## Conventions - Keep captured `.cpuprofile` files in `perf/profiles/` (gitignored). -- For diff comparisons, name them descriptively: `baseline.cpuprofile`, `step-8.cpuprofile`, `step-12.cpuprofile`, etc. -- Profiles MUST be captured in dev mode (Metro) so function names are intact. Release builds are minified and the analyzer output becomes useless. +- Name files descriptively: `baseline.cpuprofile`, `step-8.cpuprofile`, etc. +- Profiles must be captured in dev mode (Metro) so function names are intact. Release builds are minified — desymbolicate with the matching source map if you need to analyze one. diff --git a/perf/analyze-cpuprofile.js b/perf/analyze-cpuprofile.js index e34e6bdf2b..c4bae21fd9 100644 --- a/perf/analyze-cpuprofile.js +++ b/perf/analyze-cpuprofile.js @@ -192,46 +192,52 @@ function computeTotalTimes(nodesById) { } // ---------- categorization ------------------------------------------------ +// +// Generic bucketing: follow the URL, don't hand-curate. The point is to see +// where the JS thread actually goes, not to confirm a hypothesis. +// +// Buckets emitted: +// Idle — (root)/(program)/(idle) pseudo-frames +// GC — garbage collector frames +// builtin: — Hermes/V8 builtins with no URL +// (e.g. builtin:Object covers Object.assign, +// builtin:JSON covers JSON.stringify/parse) +// VM / native — other URL-less frames +// npm: — anything resolved under node_modules/ +// (scoped packages keep their @scope/name) +// SDK source — package/src/** or stream-chat-react-native/(src|lib) +// App source — anything else with a URL (consumer app code) + +const NODE_MODULES_PKG_RE = /node_modules\/(@[^/]+\/[^/]+|[^/]+)/; +const SDK_SOURCE_RE = /(?:stream-chat-react-native\/(?:package\/)?(?:src|lib)|\/package\/src)\//; +const BUILTIN_RE = /^([A-Z][A-Za-z0-9]*)\.(?:prototype\.)?[A-Za-z0-9_]+$/; function categorize(node) { const cf = node.callFrame || {}; const fn = cf.functionName || ''; const url = cf.url || ''; - // Hermes / V8 placeholder frames - if (fn === '(root)' || fn === '(program)' || fn === '(idle)') return 'IDLE/ROOT'; + if (fn === '(root)' || fn === '(program)' || fn === '(idle)') return 'Idle'; if (fn === '(garbage collector)' || fn === '(gc)' || /^gc\b/i.test(fn)) return 'GC'; - if (/react-native-markdown|markdown-it|SimpleMarkdown/i.test(url)) return 'MARKDOWN'; - if (/SimpleMarkdown|markdown/i.test(fn)) return 'MARKDOWN'; - - if ( - fn === 'JSON.stringify' || - fn === 'JSON.parse' || - fn === 'stringifyMessage' || - fn === 'reduceMessagesToString' - ) - return 'STRINGIFY/JSON'; - - if (/stream-chat\/(src|dist)/.test(url) && !/stream-chat-react-native/.test(url)) - return 'STREAM_CHAT_JS'; - - if (/\/(react|react-dom|scheduler)\/(cjs|umd)/.test(url) || /Reconciler/i.test(url)) - return 'REACT_INTERNALS'; - - if (/react-native\/Libraries/.test(url)) return 'RN_INTERNALS'; + if (!url) { + // Hermes/V8 builtins are URL-less; their function names look like + // `Object.assign`, `JSON.stringify`, `Array.prototype.map`. Bucket by + // the constructor so the cost of a whole intrinsic family aggregates. + const m = fn.match(BUILTIN_RE); + if (m) return `builtin:${m[1]}`; + return 'VM / native'; + } - if ( - /stream-chat-react-native\/(src|lib)/.test(url) || - /package\/src\//.test(url) || - /\/src\//.test(url) - ) - return 'APP_CODE (SDK)'; + // Check SDK source before npm — if the SDK is profiled from a tarball install, + // the path will contain both `node_modules/stream-chat-react-native/...` AND + // match SDK_SOURCE_RE. We want it bucketed as SDK regardless of resolution. + if (SDK_SOURCE_RE.test(url)) return 'SDK source'; - if (/node_modules\/react-native-/.test(url)) return 'RN_3P_LIBS'; + const pkg = url.match(NODE_MODULES_PKG_RE); + if (pkg) return `npm: ${pkg[1]}`; - if (!url) return 'NATIVE/OTHER'; - return 'OTHER'; + return 'App source'; } // ---------- printers ------------------------------------------------------ @@ -367,7 +373,24 @@ function printInside(nodesById, fnName, totalDurationUs) { // ---------- single-file mode -------------------------------------------- -function analyzeSingle(filePath) { +function detectUnsymbolicated(nodesById) { + // If nearly every URLed frame shares one bundle URL (e.g. `index.bundle?...`), + // the profile hasn't been mapped back to source — categorization will collapse + // into a single "App source" bucket. Warn so the user knows to desymbolicate. + const urlCounts = new Map(); + let urledFrames = 0; + for (const node of nodesById.values()) { + const url = node.callFrame && node.callFrame.url; + if (!url) continue; + urledFrames++; + urlCounts.set(url, (urlCounts.get(url) || 0) + 1); + } + if (urledFrames < 50) return false; // too small to judge + const top = [...urlCounts.values()].sort((a, b) => b - a)[0] || 0; + return top / urledFrames > 0.9 && urlCounts.size < 5; +} + +function analyzeSingle(filePath, options = {}) { const profile = loadProfile(filePath); profile.sourceFile = path.basename(filePath); const totalDurationUs = printSummary(profile); @@ -375,17 +398,29 @@ function analyzeSingle(filePath) { computeSelfTimes(profile, nodesById); computeTotalTimes(nodesById); + if (detectUnsymbolicated(nodesById)) { + console.log( + `\n⚠ This profile looks un-desymbolicated — nearly every frame shares a single bundle URL.`, + ); + console.log( + ` Per-package attribution won't work; everything will fall into a single "App source" bucket.`, + ); + console.log( + ` Desymbolicate first: node perf/desymbolicate-cpuprofile.js ${path.basename(filePath)} `, + ); + } + printByCategory(nodesById, totalDurationUs); printByFile(nodesById, totalDurationUs); printTopBySelf(nodesById, totalDurationUs); printTopByTotal(nodesById, totalDurationUs); - // Focused breakdowns for the surfaces we care about. - // These are no-ops if the function isn't in the profile (e.g., minified or never called). - printInside(nodesById, 'MessageWithContext', totalDurationUs); - printInside(nodesById, 'useCreateMessageContext', totalDurationUs); - printInside(nodesById, 'renderText', totalDurationUs); - printInside(nodesById, 'stringifyMessage', totalDurationUs); + // Optional drilldowns — pass `--inside Foo,Bar` to see what each named function + // spent its time on (self time of its descendants). No-op if a name isn't in + // the profile (minified, inlined, or never called). + for (const name of options.inside || []) { + printInside(nodesById, name, totalDurationUs); + } } // ---------- diff mode ---------------------------------------------------- @@ -453,7 +488,41 @@ function printFunctionDiff(beforeAgg, afterAgg, limit = 25) { } } -function analyzeDiff(beforePath, afterPath) { +function printFunctionDiffFiltered(beforeAgg, afterAgg, pattern) { + let re; + try { + re = new RegExp(pattern, 'i'); + } catch (e) { + console.error(`Invalid --grep pattern: ${pattern} (${e.message})`); + return; + } + const keys = new Set([...beforeAgg.byFn.keys(), ...afterAgg.byFn.keys()]); + const rows = []; + for (const k of keys) { + if (!re.test(k)) continue; + const b = beforeAgg.byFn.get(k) || 0; + const a = afterAgg.byFn.get(k) || 0; + if (b === 0 && a === 0) continue; + rows.push({ fn: k, before: b, after: a, delta: a - b }); + } + rows.sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta)); + console.log(`\n=== Function deltas matching /${pattern}/i (self time, all matches) ===`); + if (rows.length === 0) { + console.log(` (no matches)`); + return; + } + console.log( + ` ${pad('Before ms', 10)} ${pad('After ms', 10)} ${pad('Delta ms', 10)} Function`, + ); + for (const r of rows) { + const arrow = r.delta < 0 ? '↓' : r.delta > 0 ? '↑' : '·'; + console.log( + ` ${pad(microsToMs(r.before), 10)} ${pad(microsToMs(r.after), 10)} ${pad((r.delta >= 0 ? '+' : '') + microsToMs(r.delta), 10)} ${arrow} ${r.fn}`, + ); + } +} + +function analyzeDiff(beforePath, afterPath, options = {}) { console.log( `Diffing:\n before: ${path.basename(beforePath)}\n after: ${path.basename(afterPath)}`, ); @@ -465,34 +534,66 @@ function analyzeDiff(beforePath, afterPath) { console.log( `Samples — before: ${beforeAgg.profile.samples.length} / after: ${afterAgg.profile.samples.length}`, ); - console.log( - `Note: durations should be similar for a fair comparison. Large differences mean the scenario timing varied; interpret with care.`, - ); + + // Sanity check: if sample rates diverge significantly, absolute ms deltas drift. + // Relative weights remain comparable but warn the reader. + const beforeRate = beforeAgg.profile.samples.length / (beforeAgg.totalDurationUs / 1_000_000); + const afterRate = afterAgg.profile.samples.length / (afterAgg.totalDurationUs / 1_000_000); + const rateSkew = Math.abs(afterRate - beforeRate) / Math.max(beforeRate, afterRate); + if (rateSkew > 0.1) { + console.log( + `\n⚠ Sample rates differ by ${(rateSkew * 100).toFixed(1)}% (${beforeRate.toFixed(0)}/s vs ${afterRate.toFixed(0)}/s).`, + ); + console.log(` Absolute ms deltas may be misleading; trust percentages over raw ms.`); + } else { + console.log( + `Note: durations should be similar for a fair comparison. Large differences mean the scenario timing varied; interpret with care.`, + ); + } printCategoryDiff(beforeAgg, afterAgg); printFunctionDiff(beforeAgg, afterAgg); + if (options.grep) printFunctionDiffFiltered(beforeAgg, afterAgg, options.grep); } // ---------- main --------------------------------------------------------- +function parseArgs(argv) { + const out = { positional: [], inside: [], diff: false, grep: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--diff') out.diff = true; + else if (a === '--inside') out.inside = (argv[++i] || '').split(',').filter(Boolean); + else if (a === '--grep') out.grep = argv[++i] || null; + else if (a === '-h' || a === '--help') out.help = true; + else out.positional.push(a); + } + return out; +} + function main() { - const args = process.argv.slice(2); - if (args.length === 0 || args[0] === '-h' || args[0] === '--help') { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help || opts.positional.length === 0) { console.log(` Usage: - node perf/analyze-cpuprofile.js - node perf/analyze-cpuprofile.js --diff + node perf/analyze-cpuprofile.js [--inside Foo,Bar] + node perf/analyze-cpuprofile.js --diff [--grep ] + +Options: + --inside Comma-separated function names to drill into (single-file mode). + Shows where time inside each named function went. + --grep In diff mode, also print a filtered function diff matching this regex. `); - process.exit(args.length === 0 ? 1 : 0); + process.exit(opts.help ? 0 : 1); } - if (args[0] === '--diff') { - if (args.length !== 3) { + if (opts.diff) { + if (opts.positional.length !== 2) { console.error('--diff requires exactly two .cpuprofile paths'); process.exit(1); } - analyzeDiff(args[1], args[2]); + analyzeDiff(opts.positional[0], opts.positional[1], { grep: opts.grep }); } else { - analyzeSingle(args[0]); + analyzeSingle(opts.positional[0], { inside: opts.inside }); } } diff --git a/perf/capture-server.js b/perf/capture-server.js new file mode 100644 index 0000000000..d267c908c4 --- /dev/null +++ b/perf/capture-server.js @@ -0,0 +1,270 @@ +#!/usr/bin/env node +/** + * Long-form Hermes profiling capture. Connects to Metro's inspector, streams + * Tracing.dataCollected events during the entire capture (the listener is + * installed BEFORE Tracing.start, which is what makes this work — the old + * capture-hermes-profile.js installs the listener after Tracing.end and so + * only sees the post-end flush, limiting captures to ~2-3s). + * + * Usage: + * node perf/capture-server.js [label] + * PERF_MAP=path/to/bundle.map node perf/capture-server.js [label] + */ + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const readline = require('readline'); + +const METRO_HOST = process.env.METRO_HOST || 'localhost'; +const METRO_PORT = process.env.METRO_PORT || '8081'; +const PROFILES_DIR = path.join(__dirname, 'profiles'); +const MAP_PATH = process.env.PERF_MAP ? path.resolve(process.env.PERF_MAP) : null; +const LABEL = (process.argv[2] || 'capture').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80); + +fs.mkdirSync(PROFILES_DIR, { recursive: true }); + +if (typeof WebSocket === 'undefined') { + console.error('Global WebSocket missing — run on Node 22+.'); + process.exit(1); +} + +function httpGetJson(url) { + return new Promise((resolve, reject) => { + http + .get(url, (res) => { + let body = ''; + res.on('data', (d) => (body += d)); + res.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(e); + } + }); + }) + .on('error', reject); + }); +} + +function downloadToFile(url, outPath) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(outPath); + http + .get(url, (res) => { + if (res.statusCode !== 200) { + file.destroy(); + return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); + } + res.pipe(file); + file.on('finish', () => file.close(() => resolve(outPath))); + file.on('error', reject); + }) + .on('error', reject); + }); +} + +// Find the first Metro bundle URL in a Chrome trace event array (the shape +// capture-server.js writes — Tracing.dataCollected payload). We look at the +// ProfileChunk events' embedded cpuProfile nodes. +function findBundleUrlInTrace(events) { + for (const ev of events || []) { + const nodes = ev?.args?.data?.cpuProfile?.nodes; + if (!nodes) continue; + for (const n of nodes) { + const url = n?.callFrame?.url; + if (typeof url === 'string' && /\.bundle\b/.test(url)) return url; + } + } + return null; +} + +// Source map sits at the same path as the bundle, but with .bundle → .map. +// Hermes / Metro inspector emits a malformed URL like +// http://host/index.bundle//&platform=android&dev=true&... +// where the query string is glued on with `//&` instead of `?`. We can't use +// the WHATWG URL parser (it would treat `//&...` as path, not query). Parse +// regex-style, strip the leading `/*[?&]` separators, and rebuild as +// /index.map?. Also force the host — the captured URL may be +// 10.0.2.2 (Android emulator) or a LAN IP unreachable from this script. +function deriveMapUrl(bundleUrl) { + const m = bundleUrl.match(/^https?:\/\/[^/]+\/(.+?)\.bundle\/*[?&]?(.*)$/); + if (!m) return null; + const basePath = m[1]; + const query = m[2]; + return `http://${METRO_HOST}:${METRO_PORT}/${basePath}.map${query ? '?' + query : ''}`; +} + +function pickTarget(targets) { + const score = (t) => { + const s = `${t.title || ''} ${t.deviceName || ''} ${t.type || ''}`.toLowerCase(); + let n = 0; + if (s.includes('hermes')) n += 5; + if (s.includes('rnruntime') || s.includes('react native')) n += 3; + if (t.webSocketDebuggerUrl) n += 1; + return n; + }; + return targets.filter((t) => t.webSocketDebuggerUrl).sort((a, b) => score(b) - score(a))[0]; +} + +function prompt(msg) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => + rl.question(msg, (ans) => { + rl.close(); + resolve(ans); + }), + ); +} + +async function main() { + console.log(`Connecting to Metro at ${METRO_HOST}:${METRO_PORT} ...`); + let targets; + try { + targets = await httpGetJson(`http://${METRO_HOST}:${METRO_PORT}/json/list`); + } catch (e) { + console.error(`✗ Could not reach Metro. Is \`yarn workspace sampleapp start\` running?`); + process.exit(1); + } + if (!Array.isArray(targets) || targets.length === 0) { + console.error('✗ Metro reported no debug targets — is the app open?'); + process.exit(1); + } + const target = pickTarget(targets); + if (!target) { + console.error('✗ No Hermes target with a webSocketDebuggerUrl.'); + process.exit(1); + } + console.log( + ` ↳ ${target.title || '(no title)'} ${target.deviceName ? `(${target.deviceName})` : ''}`, + ); + + const ws = new WebSocket(target.webSocketDebuggerUrl); + let msgId = 0; + const pending = new Map(); + const tracingEvents = []; + + const send = (method, params = {}) => + new Promise((resolve, reject) => { + const id = ++msgId; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + }); + + // KEY: this listener has to be installed BEFORE Tracing.start, so it catches + // dataCollected events streaming during the whole capture, not just the + // post-end flush. That's what makes long captures work. + ws.addEventListener('message', (evt) => { + let msg; + try { + msg = JSON.parse(evt.data); + } catch { + return; + } + if (msg.id && pending.has(msg.id)) { + const p = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) p.reject(new Error(msg.error.message)); + else p.resolve(msg.result); + return; + } + if (msg.method === 'Tracing.dataCollected') { + const arr = msg.params?.value || []; + for (const ev of arr) tracingEvents.push(ev); + } + }); + + await new Promise((resolve, reject) => { + ws.addEventListener('open', () => resolve()); + ws.addEventListener('error', (e) => reject(new Error(`WS error: ${e.message || e}`))); + }); + + // sampling-frequency in microseconds between samples → 10000us = 100Hz. + // Lower frequency (higher value) = lighter device overhead, longer coverage. + await send('Tracing.start', { + categories: '-*,disabled-by-default-v8.cpu_profiler.hires,disabled-by-default-v8.cpu_profiler', + options: 'sampling-frequency=10000', + transferMode: 'ReportEvents', + }); + + console.log(`\nTracing started. Run your scenario on device.`); + await prompt(`Press Enter to STOP and save: `); + + console.log('Stopping ...'); + await send('Tracing.end'); + // Wait briefly for any tail dataCollected events Hermes still has buffered. + await new Promise((r) => setTimeout(r, 1500)); + try { + ws.close(); + } catch { + /* ignore */ + } + + if (tracingEvents.length === 0) { + console.error('✗ No samples collected — was the app idle the whole time?'); + process.exit(1); + } + + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const outPath = path.join(PROFILES_DIR, `${LABEL}-${ts}.cpuprofile`); + fs.writeFileSync(outPath, JSON.stringify(tracingEvents)); + const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1); + console.log(`Saved ${sizeKb} KB → ${path.relative(process.cwd(), outPath)}\n`); + + // Resolve the source map. Priority: + // 1) PERF_MAP= — explicit override (release builds, offline cases). + // 2) Auto-fetch from Metro using the bundle URL embedded in the profile. + // 3) Skip entirely if PERF_SKIP_DESYM=1. + // Auto-fetched maps go to /tmp and get cleaned up after desym runs. + let mapPath = MAP_PATH && fs.existsSync(MAP_PATH) ? MAP_PATH : null; + let autoFetchedMap = null; + if (!mapPath && !process.env.PERF_SKIP_DESYM) { + const bundleUrl = findBundleUrlInTrace(tracingEvents); + const mapUrl = bundleUrl ? deriveMapUrl(bundleUrl) : null; + if (!mapUrl) { + console.log('No .bundle URL in profile — skipping auto-desymbolication.'); + } else { + const tmpMap = path.join(os.tmpdir(), `metro-${Date.now()}.map.json`); + console.log(`Fetching source map:\n ${mapUrl}`); + try { + await downloadToFile(mapUrl, tmpMap); + mapPath = tmpMap; + autoFetchedMap = tmpMap; + } catch (e) { + console.error(`Could not fetch source map (${e.message}) — analyzing raw profile.`); + } + } + } + + let analyzeTarget = outPath; + if (mapPath && fs.existsSync(mapPath)) { + console.log(`Desymbolicating with ${mapPath} ...`); + const desym = spawnSync( + process.execPath, + [path.join(__dirname, 'desymbolicate-cpuprofile.js'), outPath, mapPath], + { stdio: 'inherit' }, + ); + if (desym.status === 0) { + const candidate = outPath.replace(/\.cpuprofile$/, '') + '.desymbolicated.cpuprofile'; + if (fs.existsSync(candidate)) analyzeTarget = candidate; + } else { + console.error('Desymbolication failed — analyzing raw profile.'); + } + if (autoFetchedMap && fs.existsSync(autoFetchedMap)) fs.unlinkSync(autoFetchedMap); + } + + console.log(`\nAnalyzing ${path.basename(analyzeTarget)} ...\n`); + const analyzerArgs = [path.join(__dirname, 'analyze-cpuprofile.js'), analyzeTarget]; + if (process.env.PERF_INSIDE) { + analyzerArgs.push('--inside', process.env.PERF_INSIDE); + } + const r = spawnSync(process.execPath, analyzerArgs, { stdio: 'inherit' }); + process.exit(r.status ?? 0); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/perf/desymbolicate-cpuprofile.js b/perf/desymbolicate-cpuprofile.js new file mode 100644 index 0000000000..0b9562a39c --- /dev/null +++ b/perf/desymbolicate-cpuprofile.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * Rewrite a Hermes/V8 .cpuprofile so its frames point at original source + * locations instead of minified bundle offsets. Uses a single Metro source map. + * + * Usage: + * node perf/desymbolicate-cpuprofile.js [-o ] + * + * Input profile may be either: + * - V8 .cpuprofile JSON ({ nodes, samples, timeDeltas, ... }) + * - Chrome trace event array (RN 0.81 Bridgeless / Fusebox Tracing format) + * + * Output is always V8 .cpuprofile JSON, which analyze-cpuprofile.js consumes. + * + * Per frame we look up (line + 1, column) in the source map and rewrite: + * callFrame.url -> original source path (e.g. package/src/foo.tsx) + * callFrame.lineNumber -> original line - 1 (cpuprofile is 0-indexed) + * callFrame.columnNumber -> original column + * callFrame.functionName -> source map `name` if present, else keep existing + * + * Pseudo-frames ((root), (idle), (gc), GC, builtins with no URL) are passed + * through untouched. + */ + +const fs = require('fs'); +const path = require('path'); + +const { SourceMapConsumer } = require('source-map'); + +function parseArgs(argv) { + const out = { positional: [], output: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '-o' || a === '--output') out.output = argv[++i]; + else if (a === '-h' || a === '--help') out.help = true; + else out.positional.push(a); + } + return out; +} + +function loadJson(p, label) { + if (!fs.existsSync(p)) { + console.error(`${label} not found: ${p}`); + process.exit(1); + } + try { + return JSON.parse(fs.readFileSync(p, 'utf8')); + } catch (e) { + console.error(`${label} is not valid JSON: ${p} (${e.message})`); + process.exit(1); + } +} + +// Normalize input to a V8 .cpuprofile shape so the output is always uniform. +// Mirrors analyze-cpuprofile.js's chromeTraceToV8Profile conversion. +function normalizeToV8(profile) { + if (!Array.isArray(profile)) return profile; + const profileEvent = profile.find((e) => e.name === 'Profile'); + const chunkEvents = profile.filter((e) => e.name === 'ProfileChunk'); + const startTime = profileEvent?.args?.data?.startTime || 0; + const nodesById = new Map(); + const samples = []; + const timeDeltas = []; + for (const c of chunkEvents) { + const d = c.args?.data || {}; + const chunkNodes = d.cpuProfile?.nodes || []; + const chunkSamples = d.cpuProfile?.samples || []; + const chunkDeltas = d.timeDeltas || []; + for (const n of chunkNodes) { + if (!nodesById.has(n.id)) { + const cf = { ...(n.callFrame || {}) }; + if (typeof cf.scriptId === 'number') cf.scriptId = String(cf.scriptId); + nodesById.set(n.id, { + id: n.id, + callFrame: cf, + parent: n.parent || null, + hitCount: 0, + children: [], + }); + } + } + for (const s of chunkSamples) samples.push(s); + for (const dt of chunkDeltas) timeDeltas.push(dt); + } + for (const node of nodesById.values()) { + if (node.parent != null) { + const p = nodesById.get(node.parent); + if (p) p.children.push(node.id); + } + } + const totalUs = timeDeltas.reduce((a, b) => a + b, 0); + return { + nodes: [...nodesById.values()], + samples, + timeDeltas, + startTime, + endTime: startTime + totalUs, + }; +} + +function isPseudoFrame(cf) { + const fn = cf.functionName || ''; + if (fn === '(root)' || fn === '(program)' || fn === '(idle)') return true; + if (fn === '(garbage collector)' || fn === '(gc)') return true; + return false; +} + +function rewriteFrames(profile, consumer) { + let rewritten = 0; + let unmapped = 0; + let skipped = 0; + for (const node of profile.nodes) { + const cf = node.callFrame; + if (!cf) { + skipped++; + continue; + } + if (isPseudoFrame(cf) || !cf.url) { + skipped++; + continue; + } + if (typeof cf.lineNumber !== 'number') { + skipped++; + continue; + } + // cpuprofile lineNumber/columnNumber are 0-indexed; + // source-map originalPositionFor expects 1-indexed line, 0-indexed column. + const orig = consumer.originalPositionFor({ + line: cf.lineNumber + 1, + column: cf.columnNumber || 0, + }); + if (!orig || orig.source == null) { + unmapped++; + continue; + } + cf.url = orig.source; + cf.lineNumber = (orig.line || 1) - 1; + cf.columnNumber = orig.column || 0; + if (orig.name) cf.functionName = orig.name; + rewritten++; + } + return { rewritten, unmapped, skipped }; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help || opts.positional.length !== 2) { + console.log(` +Usage: + node perf/desymbolicate-cpuprofile.js [-o ] + +If -o is omitted, writes .desymbolicated.cpuprofile next to the input. +`); + process.exit(opts.help ? 0 : 1); + } + const [profilePath, mapPath] = opts.positional; + const outPath = + opts.output || profilePath.replace(/\.cpuprofile$/, '') + '.desymbolicated.cpuprofile'; + + console.log(`Loading profile: ${path.basename(profilePath)}`); + const raw = loadJson(profilePath, 'profile'); + const profile = normalizeToV8(raw); + if (!profile.nodes) { + console.error('Input does not look like a profile (no nodes after normalization).'); + process.exit(1); + } + console.log(` ${profile.nodes.length} call frames, ${profile.samples?.length || 0} samples`); + + console.log(`Loading source map: ${path.basename(mapPath)}`); + const rawMap = loadJson(mapPath, 'source map'); + const consumer = new SourceMapConsumer(rawMap); + + console.log('Rewriting frames ...'); + const { rewritten, unmapped, skipped } = rewriteFrames(profile, consumer); + consumer.destroy && consumer.destroy(); + + console.log(` rewritten: ${rewritten}`); + console.log(` unmapped: ${unmapped} (source map didn't cover the position)`); + console.log(` skipped: ${skipped} (pseudo-frames, builtins, missing line)`); + + fs.writeFileSync(outPath, JSON.stringify(profile)); + const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1); + console.log(`\nWrote ${sizeKb} KB → ${outPath}`); + console.log(`\nNext: node perf/analyze-cpuprofile.js ${outPath}`); +} + +main(); From 4ede4a0b4911301429653a0a2813aaea57384480 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 29 May 2026 19:09:53 +0200 Subject: [PATCH 7/8] chore: remove redundant bench --- .../useCreateMessageContext.bench.ts | 174 ------------------ 1 file changed, 174 deletions(-) delete mode 100644 package/src/components/Message/hooks/__tests__/useCreateMessageContext.bench.ts diff --git a/package/src/components/Message/hooks/__tests__/useCreateMessageContext.bench.ts b/package/src/components/Message/hooks/__tests__/useCreateMessageContext.bench.ts deleted file mode 100644 index db24a4f7b0..0000000000 --- a/package/src/components/Message/hooks/__tests__/useCreateMessageContext.bench.ts +++ /dev/null @@ -1,174 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/** - * Microbenchmark for Step 8 — measures the actual cost of the eager - * stringifications in `useCreateMessageContext` against the proposed - * Option B (ref/primitive deps replacing the stringifies). - * - * Run: cd package && TZ=UTC npx jest useCreateMessageContext.bench - * - * NOTE: Numbers are from Node V8 (jest), not Hermes-on-Android. The - * relative magnitudes carry; absolute numbers will be ~2-5x slower - * on a mid-range Android device. Use this for "which is bigger" and - * "how big roughly," not for production budget claims. - * - * `as any` casts are intentional — fixtures intentionally widen types to - * pack heavy reaction/i18n payloads that the strict mock-builder types - * would otherwise reject. - */ -import { generateMember } from '../../../../mock-builders/generator/member'; -import { generateMessage } from '../../../../mock-builders/generator/message'; -import { generateReaction } from '../../../../mock-builders/generator/reaction'; -import { generateUser } from '../../../../mock-builders/generator/user'; -import { stringifyMessage } from '../../../../utils/utils'; - -// --- Fixtures ------------------------------------------------------------- - -const shortPlainMessage = generateMessage({ text: 'hello' }); - -const longTextMessage = generateMessage({ - text: 'a'.repeat(500), - i18n: { - en_text: 'a'.repeat(500), - fr_text: 'b'.repeat(500), - language: 'en', - } as any, -}); - -const heavyReactionsMessage = generateMessage({ - text: 'message with reactions', - latest_reactions: Array.from({ length: 20 }, (_, i) => - generateReaction({ type: ['like', 'love', 'haha', 'wow'][i % 4], user_id: `u${i}` } as any), - ) as any, - reaction_groups: { - like: { count: 8, sum_scores: 8, first_reaction_at: new Date(), last_reaction_at: new Date() }, - love: { count: 6, sum_scores: 6, first_reaction_at: new Date(), last_reaction_at: new Date() }, - haha: { count: 4, sum_scores: 4, first_reaction_at: new Date(), last_reaction_at: new Date() }, - wow: { count: 2, sum_scores: 2, first_reaction_at: new Date(), last_reaction_at: new Date() }, - } as any, -}); - -const quotedMessage = generateMessage({ - text: 'a quoted reply', - quoted_message: generateMessage({ text: 'the original message being quoted' }) as any, -}); - -const members50 = Object.fromEntries( - Array.from({ length: 50 }, () => { - const user = generateUser(); - return [user.id, generateMember({ user })]; - }), -); - -// --- Bench harness -------------------------------------------------------- - -const ITERATIONS = 100_000; -const WARMUP = 5_000; - -function bench(label: string, fn: () => unknown) { - // warmup - for (let i = 0; i < WARMUP; i++) fn(); - // measure - const start = process.hrtime.bigint(); - for (let i = 0; i < ITERATIONS; i++) fn(); - const end = process.hrtime.bigint(); - const totalNs = Number(end - start); - const perCallNs = totalNs / ITERATIONS; - const perCallUs = perCallNs / 1000; - console.log( - ` ${label.padEnd(60)} ${perCallUs.toFixed(3).padStart(8)} µs/call (${(totalNs / 1_000_000).toFixed(1)} ms total / ${ITERATIONS.toLocaleString()} iters)`, - ); - return perCallUs; -} - -describe('Step 8 microbenchmark', () => { - // Don't fail the test on time variance — this is observational, not assertive. - jest.setTimeout(120_000); - - it('measures the four eager stringifications in useCreateMessageContext', () => { - console.log('\n=== CURRENT BEHAVIOR (eager stringifications per call) ===\n'); - - console.log('-- stringifyMessage --'); - const sShort = bench('short plain message', () => - stringifyMessage({ message: shortPlainMessage }), - ); - const sLong = bench('long-text message (500 chars + i18n)', () => - stringifyMessage({ message: longTextMessage }), - ); - const sHeavy = bench('message with 20 reactions + 4 reaction groups', () => - stringifyMessage({ message: heavyReactionsMessage }), - ); - const sQuoted = bench('quoted-message stringify (includeReactions: false)', () => - stringifyMessage({ message: quotedMessage.quoted_message as any, includeReactions: false }), - ); - - console.log('\n-- members stringify --'); - const m50 = bench('JSON.stringify(members) — 50 members', () => JSON.stringify(members50)); - - console.log('\n-- reactions stringify (the local `reactionsValue` line) --'); - const reactions20 = heavyReactionsMessage.latest_reactions as any[]; - const reactionsEmpty: any[] = []; - const r20 = bench('reactions.map().join() — 20 reactions', () => - reactions20.map(({ count, own, type }: any) => `${own}${type}${count}`).join(), - ); - const rEmpty = bench('reactions.map().join() — 0 reactions', () => - reactionsEmpty.map(({ count, own, type }: any) => `${own}${type}${count}`).join(), - ); - - console.log('\n=== TOTAL per useCreateMessageContext call (realistic mix) ===\n'); - const totalShort = sShort + m50 + rEmpty; - const totalLong = sLong + m50 + rEmpty; - const totalHeavy = sHeavy + m50 + r20 + sQuoted; - bench(`(reference) short plain msg + 50 members, no reactions`, () => {}); - console.log(` summed cost: ${totalShort.toFixed(3)} µs/call`); - console.log(` long text : ${totalLong.toFixed(3)} µs/call`); - console.log(` heavy msg : ${totalHeavy.toFixed(3)} µs/call`); - - console.log( - '\n=== OPTION B EQUIVALENT (deps-only — Object.is checks on refs/primitives) ===\n', - ); - - // Simulating "the cost of having the deps array compare 25 entries" — what - // useMemo does internally on a hit/miss check. - const prevDeps: any[] = [ - true, // actionsEnabled - 'left', // alignment - () => {}, // goToMessage - [], // stableGroupStyles - false, // hasAttachmentActions - false, // hasReactions - false, // messageHasOnlySingleAttachment - false, // lastGroupMessage - '{}', // myMessageThemeString - 'overlay-id', // messageOverlayId - [], // readBy - 0, // deliveredToCount - true, // showAvatar - true, // showMessageStatus - false, // threadList - false, // preventPress - () => {}, // unregisterMessageOverlayTarget - members50, // members - heavyReactionsMessage.type, // message.type - heavyReactionsMessage.deleted_at, // message.deleted_at - heavyReactionsMessage.text, // message.text - heavyReactionsMessage.reply_count, // message.reply_count - heavyReactionsMessage.status, // message.status - heavyReactionsMessage.updated_at, // message.updated_at - heavyReactionsMessage.i18n, // message.i18n - heavyReactionsMessage.attachments, // message.attachments - heavyReactionsMessage.latest_reactions, // message.latest_reactions - heavyReactionsMessage.reaction_groups, // message.reaction_groups - ]; - const nextDeps = [...prevDeps]; // simulate "memo hit": all refs identical - bench(`28-dep array compare via Object.is (memo hit, all refs equal)`, () => { - // Exactly what areHookInputsEqual does in React internals. - for (let i = 0; i < prevDeps.length; i++) { - if (!Object.is(prevDeps[i], nextDeps[i])) break; - } - }); - - // Verifies the bench compiled & returns truthy results so jest doesn't fail - expect(sShort).toBeGreaterThan(0); - expect(m50).toBeGreaterThan(0); - }); -}); From ad5e529f95b1c49b63f77b63a8809ed0efb35d9f Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 29 May 2026 20:42:18 +0200 Subject: [PATCH 8/8] fix: heading markdowns --- .../MessageItemView/utils/renderText.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/package/src/components/Message/MessageItemView/utils/renderText.tsx b/package/src/components/Message/MessageItemView/utils/renderText.tsx index feb3b39f6f..9061cca175 100644 --- a/package/src/components/Message/MessageItemView/utils/renderText.tsx +++ b/package/src/components/Message/MessageItemView/utils/renderText.tsx @@ -101,6 +101,37 @@ const defaultMarkdownStyles: MarkdownStyle = { fontSize: primitives.typographyFontSizeMd, lineHeight: primitives.typographyLineHeightNormal, }, + // Heading sizes are derived from the body font size (`typographyFontSizeMd`) so they + // scale with the integrator's typography settings. lineHeight = fontSize × 1.25 to + // give headings room to breathe. Both fields are required here: without lineHeight, + // the inherited `lineHeight: typographyLineHeightNormal` (20) from `styles.text` (set + // in renderText below) leaks into the heading's inner Text via the markdown library's + // text rule (`{...styles.text, ...state.style}`) and squishes larger heading fontSizes + // into a 20px line box. + heading1: { + fontSize: primitives.typographyFontSizeMd * 2, + lineHeight: primitives.typographyFontSizeMd * 2 * 1.25, + }, + heading2: { + fontSize: primitives.typographyFontSizeMd * 1.5, + lineHeight: primitives.typographyFontSizeMd * 1.5 * 1.25, + }, + heading3: { + fontSize: primitives.typographyFontSizeMd * 1.25, + lineHeight: primitives.typographyFontSizeMd * 1.25 * 1.25, + }, + heading4: { + fontSize: primitives.typographyFontSizeMd, + lineHeight: primitives.typographyFontSizeMd * 1.25, + }, + heading5: { + fontSize: primitives.typographyFontSizeMd * 0.875, + lineHeight: primitives.typographyFontSizeMd * 0.875 * 1.25, + }, + heading6: { + fontSize: primitives.typographyFontSizeMd * 0.75, + lineHeight: primitives.typographyFontSizeMd * 0.75 * 1.25, + }, inlineCode: { padding: primitives.spacingXxs, paddingHorizontal: primitives.spacingXxs,