Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions examples/SampleApp/metro.config.no-dev.js
Original file line number Diff line number Diff line change
@@ -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);
};
},
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
68 changes: 32 additions & 36 deletions package/src/components/Message/MessageItemView/MessageContent.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<ViewStyle>(() => {
// 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<string, string | AnimatableNumericValue | undefined> = {
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);
Expand Down Expand Up @@ -357,12 +356,8 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
<View
style={[
styles.containerInner,
{
backgroundColor,
...getBorderRadius(),
...getBorderRadiusFromTheme(),
},
noBorder ? { borderWidth: 0 } : {},
bubbleColorAndRadius,
noBorder ? styles.noBorder : null,
containerInner,
messageGroupedSingleOrBottom
? isVeryLastMessage && enableMessageGroupingByUser
Expand Down Expand Up @@ -684,6 +679,7 @@ const styles = StyleSheet.create({
alignSelf: 'center',
},
galleryContainer: {},
noBorder: { borderWidth: 0 },
rightAlignContent: {
justifyContent: 'flex-end',
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ exports[`Thread should match thread snapshot 1`] = `
"borderBottomLeftRadius": 0,
"borderBottomRightRadius": 20,
},
{},
null,
{},
{},
]
Expand Down Expand Up @@ -843,7 +843,7 @@ exports[`Thread should match thread snapshot 1`] = `
"borderBottomLeftRadius": 0,
"borderBottomRightRadius": 20,
},
{},
null,
{},
{},
]
Expand Down Expand Up @@ -1208,7 +1208,7 @@ exports[`Thread should match thread snapshot 1`] = `
"borderBottomLeftRadius": 0,
"borderBottomRightRadius": 20,
},
{},
null,
{},
{},
]
Expand Down Expand Up @@ -1534,7 +1534,7 @@ exports[`Thread should match thread snapshot 1`] = `
"borderBottomLeftRadius": 0,
"borderBottomRightRadius": 20,
},
{},
null,
{},
{},
]
Expand Down
2 changes: 2 additions & 0 deletions perf/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
profiles/
*.cpuprofile
75 changes: 75 additions & 0 deletions perf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# perf/

Profiling tooling for the SDK row-render perf initiative.

## Capture a `.cpuprofile`

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.

## Analyze a single profile

```sh
node perf/analyze-cpuprofile.js perf/profiles/baseline.cpuprofile
```

Outputs:

- Profile summary (duration, sample count, sample rate).
- ⚠ warning if the profile looks un-desymbolicated.
- **Time by category** — auto-bucketed by source: `Idle`, `GC`, `npm: <package>`, `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).

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)

```sh
node perf/analyze-cpuprofile.js --diff perf/profiles/before.cpuprofile perf/profiles/after.cpuprofile
```

Per-category self-time delta + top function self-time deltas (sorted by `|delta|`). Optional `--grep <pattern>` 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).
- 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.
Loading
Loading