From f133b08eeebed1df98b5246fffb3e65d1c6f2685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 16:41:07 +0200 Subject: [PATCH 1/2] test(test-app): add a perf lab with seeded React render problems --- examples/test-app/README.md | 27 ++ examples/test-app/app/_layout.tsx | 1 + examples/test-app/app/perf-lab.tsx | 9 + .../test-app/src/screens/PerfLabScreen.tsx | 343 ++++++++++++++++++ 4 files changed, 380 insertions(+) create mode 100644 examples/test-app/app/perf-lab.tsx create mode 100644 examples/test-app/src/screens/PerfLabScreen.tsx diff --git a/examples/test-app/README.md b/examples/test-app/README.md index 9b71797bd9..7d28452b87 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -18,6 +18,7 @@ It is intentionally small, but each surface is dense with durable accessibility - `Settings`: switch rows, accordion content, loading and error states, retry flow, destructive-confirm modal - `Automation lab`: long-press, alert-result, app-event, app-state, appearance, orientation, permission-recovery, log canaries, a flattened (`accessible={true}`) text input, and an Apple Pay sheet hosted in `com.apple.PassbookUIService` - `WebView accessibility`: a deterministic semantic fixture plus live websites with varied HTML for native accessibility snapshot verification +- `Perf lab`: seeded JavaScript render problems for `react-devtools profile`, one per scenario (see [Perf lab](#perf-lab)) Navigation uses Expo Router native bottom tabs, so the tab bar itself is also part of the test surface. @@ -234,6 +235,32 @@ installed development build is connected to that server. For Android local device/emulator runs, also run `adb reverse tcp:8082 tcp:8082` when the device needs host port forwarding. +## Perf lab + +`agent-device-test-app://perf-lab?scenario=` opens one seeded render problem. **Run** (`perf-run`) replays the slow interaction as a fixed burst and sets `perf-status` to `done`. Profile it with: + +```bash +agent-device open "agent-device-test-app://perf-lab?scenario=memo-defeated" --platform android +agent-device react-devtools profile start +agent-device click 'id="perf-run"' +agent-device wait 'id="perf-status" text="done"' 10000 +agent-device react-devtools profile stop +``` + +`profile stop` should report this finding first: + +| Scenario | Seeded problem | Expected finding | +| --- | --- | --- | +| `expensive-render` | `PriceHistoryChart` recomputes its trend on every render | `slow-render PriceHistoryChart` | +| `memo-defeated` | `ContactList` passes a new callback and style object to memoized rows | `memo-defeated ContactRow` | +| `context-cascade` | `SessionProvider` builds a new context value on every update | `context-cascade AvatarBadge` | +| `parent-cascade` | Typing re-renders every unmemoized result row | `parent-cascade ResultRow` | +| `render-storm` | `LiveClock` sets state every 16 ms | `render-storm LiveClock` | +| `effect-chain` | `FilteredContacts` copies derived state in an effect | `effect-chain FilteredContacts` | +| `eager-list` | 1,200 tiles mount in one commit instead of a virtualized list | `eager-mount ProductTile` | + +`src/screens/PerfLabScreen.tsx` imports only React and React Native, so it can be copied into a bare React Native app to check the profiler on other React Native versions. + ## Local Agent Device suites The repo includes two local suites for iterating on the fixture app: diff --git a/examples/test-app/app/_layout.tsx b/examples/test-app/app/_layout.tsx index 4a48fdf146..e64205f5f4 100644 --- a/examples/test-app/app/_layout.tsx +++ b/examples/test-app/app/_layout.tsx @@ -29,6 +29,7 @@ function RootLayoutContent() { + diff --git a/examples/test-app/app/perf-lab.tsx b/examples/test-app/app/perf-lab.tsx new file mode 100644 index 0000000000..a4a0b4c2af --- /dev/null +++ b/examples/test-app/app/perf-lab.tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from 'expo-router'; + +import { isPerfScenario, PerfLabScreen } from '../src/screens/PerfLabScreen'; + +export default function PerfLabRoute() { + const { scenario } = useLocalSearchParams<{ scenario?: string }>(); + const initialScenario = isPerfScenario(scenario) ? scenario : undefined; + return ; +} diff --git a/examples/test-app/src/screens/PerfLabScreen.tsx b/examples/test-app/src/screens/PerfLabScreen.tsx new file mode 100644 index 0000000000..83764e49e3 --- /dev/null +++ b/examples/test-app/src/screens/PerfLabScreen.tsx @@ -0,0 +1,343 @@ +import { + createContext, + memo, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; + +/** + * Seeded JavaScript performance problems for `react-devtools profile`. Each scenario renders one + * culprit and a Run button that replays the slow interaction as a fixed burst, then shows + * `done` in `perf-status`. The file imports only React and React Native so it can be dropped into + * a bare React Native app to check the profiler on other versions. + */ +export const PERF_SCENARIOS = [ + 'expensive-render', + 'memo-defeated', + 'context-cascade', + 'parent-cascade', + 'render-storm', + 'effect-chain', + 'eager-list', +] as const; + +export type PerfScenario = (typeof PERF_SCENARIOS)[number]; + +export function isPerfScenario(value: unknown): value is PerfScenario { + return PERF_SCENARIOS.includes(value as PerfScenario); +} + +type ScenarioProps = { runId: number; onDone: () => void }; + +export function PerfLabScreen({ initialScenario }: { initialScenario?: PerfScenario }) { + const [scenario, setScenario] = useState(initialScenario ?? 'expensive-render'); + const [runId, setRunId] = useState(0); + const [status, setStatus] = useState('idle'); + const Scenario = SCENARIO_COMPONENTS[scenario]; + + return ( + + Perf lab + + {PERF_SCENARIOS.map((id) => ( + { + setScenario(id); + setRunId(0); + setStatus('idle'); + }} + style={[styles.chip, id === scenario && styles.chipSelected]} + testID={`perf-scenario-${id}`} + > + {id} + + ))} + + + { + setStatus('running'); + setRunId((value) => value + 1); + }} + style={styles.run} + testID="perf-run" + > + Run {scenario} + + {status} + + setStatus('done')} runId={runId} /> + + ); +} + +const SCENARIO_COMPONENTS: Record ReactNode> = { + 'expensive-render': ExpensiveRenderScenario, + 'memo-defeated': MemoDefeatedScenario, + 'context-cascade': ContextCascadeScenario, + 'parent-cascade': ParentCascadeScenario, + 'render-storm': RenderStormScenario, + 'effect-chain': EffectChainScenario, + 'eager-list': EagerListScenario, +}; + +/** Calls `step` `count` times, `intervalMs` apart, after each new run; then reports done. */ +function useBurst( + runId: number, + count: number, + intervalMs: number, + step: (index: number) => void, + onDone: () => void, +) { + const stepRef = useRef(step); + stepRef.current = step; + const doneRef = useRef(onDone); + doneRef.current = onDone; + useEffect(() => { + if (runId === 0) return; + let index = 0; + const timer = setInterval(() => { + if (index >= count) { + clearInterval(timer); + doneRef.current(); + return; + } + stepRef.current(index); + index += 1; + }, intervalMs); + return () => clearInterval(timer); + }, [runId, count, intervalMs]); +} + +function busyWork(iterations: number, seed: number): number { + let value = seed; + for (let index = 0; index < iterations; index += 1) { + value = (value * 1103515245 + 12345) % 2147483647; + } + return value; +} + +// expensive-render: the chart recomputes its trend line on every render instead of memoizing it. +function ExpensiveRenderScenario({ runId, onDone }: ScenarioProps) { + const [refreshes, setRefreshes] = useState(0); + useBurst(runId, 6, 150, () => setRefreshes((value) => value + 1), onDone); + return ( + + Refreshes {refreshes} + + + ); +} + +function PriceHistoryChart({ refreshes }: { refreshes: number }) { + const trend = busyWork(1_500_000, refreshes + 1); + return Trend {trend % 1000}; +} + +// memo-defeated: the rows are memoized, but the list passes a fresh callback and style object. +function MemoDefeatedScenario({ runId, onDone }: ScenarioProps) { + const [selected, setSelected] = useState(-1); + useBurst(runId, 6, 150, (index) => setSelected(index), onDone); + return ; +} + +function ContactList({ selected, onSelect }: { selected: number; onSelect: (id: number) => void }) { + return ( + + {CONTACTS.map((contact) => ( + onSelect(contact.id)} + /> + ))} + + ); +} + +const ContactRow = memo(function ContactRow({ + contact, + highlight, + onSelect, +}: { + contact: Contact; + highlight: { opacity: number }; + onSelect: () => void; +}) { + busyWork(20_000, contact.id); + return ( + + {contact.name} + + ); +}); + +// context-cascade: the provider value is a new object on every tick, so every badge re-renders +// even though only the unrelated `unread` counter changes. +type Session = { user: string; unread: number }; +const SessionContext = createContext({ user: 'guest', unread: 0 }); + +function ContextCascadeScenario({ runId, onDone }: ScenarioProps) { + const [unread, setUnread] = useState(0); + useBurst(runId, 6, 150, () => setUnread((value) => value + 1), onDone); + return ( + + Unread {unread} + + + ); +} + +function SessionProvider({ unread, children }: { unread: number; children: ReactNode }) { + return ( + {children} + ); +} + +const BadgeGrid = memo(function BadgeGrid() { + return ( + + {CONTACTS.map((contact) => ( + + ))} + + ); +}); + +function AvatarBadge({ seed }: { seed: number }) { + const { user } = useContext(SessionContext); + busyWork(15_000, seed); + return {user.slice(0, 1)}; +} + +// parent-cascade: typing updates the screen's state and re-renders every unmemoized result row. +function ParentCascadeScenario({ runId, onDone }: ScenarioProps) { + const [query, setQuery] = useState(''); + useBurst(runId, 8, 100, (index) => setQuery('searching'.slice(0, index + 1)), onDone); + return ; +} + +function SearchResults({ + query, + onChangeQuery, +}: { + query: string; + onChangeQuery: (value: string) => void; +}) { + return ( + + + + {CONTACTS.map((contact) => ( + + ))} + + + ); +} + +function ResultRow({ contact }: { contact: Contact }) { + busyWork(15_000, contact.id); + return ( + + {contact.name} + + ); +} + +const MemoResultRow = memo(ResultRow); + +// render-storm: a clock drives React state from a 16 ms timer, committing every frame. +function RenderStormScenario({ runId, onDone }: ScenarioProps) { + return ; +} + +function LiveClock({ runId, onDone }: ScenarioProps) { + const [now, setNow] = useState(0); + useBurst(runId, 90, 16, () => setNow(Date.now()), onDone); + return {now % 100_000}; +} + +// effect-chain: derived state is copied in an effect, so every change commits twice. +function EffectChainScenario({ runId, onDone }: ScenarioProps) { + const [items, setItems] = useState(() => CONTACTS.slice(0, 20)); + useBurst(runId, 6, 150, (index) => setItems(CONTACTS.slice(index, index + 20)), onDone); + return ; +} + +function FilteredContacts({ items }: { items: Contact[] }) { + const [visible, setVisible] = useState([]); + useEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect -- the seeded effect-chain problem + setVisible(items.filter((contact) => contact.id % 2 === 0)); + }, [items]); + return ( + + {visible.map((contact) => ( + + ))} + + ); +} + +// eager-list: showing the catalog mounts every tile at once instead of virtualizing the list. +function EagerListScenario({ runId, onDone }: ScenarioProps) { + const [shown, setShown] = useState(false); + useBurst(runId, 1, 50, () => setShown((value) => !value), onDone); + return ( + + {shown ? CATALOG.map((id) => ) : null} + + ); +} + +function ProductTile({ id }: { id: number }) { + return ( + + Product {id} + {(id * 7) % 100} in stock + + ); +} + +type Contact = { id: number; name: string }; + +const CONTACTS: Contact[] = Array.from({ length: 60 }, (_, id) => ({ id, name: `Contact ${id}` })); +const CATALOG = Array.from({ length: 1200 }, (_, id) => id); + +const styles = StyleSheet.create({ + frame: { flex: 1, padding: 16, paddingTop: 48, gap: 8 }, + title: { fontSize: 20, fontWeight: '600' }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + chip: { + borderRadius: 12, + borderWidth: 1, + borderColor: '#999', + paddingHorizontal: 8, + paddingVertical: 4, + }, + chipSelected: { backgroundColor: '#dde8ff' }, + chipLabel: { fontSize: 12 }, + toolbar: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + run: { backgroundColor: '#2f5bd3', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 8 }, + runLabel: { color: 'white', fontWeight: '600' }, + list: { flexGrow: 0, maxHeight: 420 }, + row: { paddingVertical: 4 }, + grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 4 }, + badge: { width: 24, textAlign: 'center' }, + input: { borderWidth: 1, borderColor: '#999', borderRadius: 6, padding: 6 }, + tile: { paddingVertical: 2 }, +}); From c51097fd1d601b50ba371bdd5f313c65d6336f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 17:42:26 +0200 Subject: [PATCH 2/2] test(test-app): remount the eager list on every run and describe the context seed --- examples/test-app/README.md | 4 ++-- examples/test-app/src/screens/PerfLabScreen.tsx | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/examples/test-app/README.md b/examples/test-app/README.md index 7d28452b87..624666dcd2 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -253,11 +253,11 @@ agent-device react-devtools profile stop | --- | --- | --- | | `expensive-render` | `PriceHistoryChart` recomputes its trend on every render | `slow-render PriceHistoryChart` | | `memo-defeated` | `ContactList` passes a new callback and style object to memoized rows | `memo-defeated ContactRow` | -| `context-cascade` | `SessionProvider` builds a new context value on every update | `context-cascade AvatarBadge` | +| `context-cascade` | A fast-changing `unread` counter shares one context with the stable user name that badges read | `context-cascade AvatarBadge` | | `parent-cascade` | Typing re-renders every unmemoized result row | `parent-cascade ResultRow` | | `render-storm` | `LiveClock` sets state every 16 ms | `render-storm LiveClock` | | `effect-chain` | `FilteredContacts` copies derived state in an effect | `effect-chain FilteredContacts` | -| `eager-list` | 1,200 tiles mount in one commit instead of a virtualized list | `eager-mount ProductTile` | +| `eager-list` | 1,200 tiles mount in one commit instead of a virtualized list; every Run mounts them afresh | `eager-mount ProductTile` | `src/screens/PerfLabScreen.tsx` imports only React and React Native, so it can be copied into a bare React Native app to check the profiler on other React Native versions. diff --git a/examples/test-app/src/screens/PerfLabScreen.tsx b/examples/test-app/src/screens/PerfLabScreen.tsx index 83764e49e3..3bd2392e99 100644 --- a/examples/test-app/src/screens/PerfLabScreen.tsx +++ b/examples/test-app/src/screens/PerfLabScreen.tsx @@ -180,8 +180,8 @@ const ContactRow = memo(function ContactRow({ ); }); -// context-cascade: the provider value is a new object on every tick, so every badge re-renders -// even though only the unrelated `unread` counter changes. +// context-cascade: the context carries a fast-changing `unread` counter next to the stable `user` +// name, so every badge that reads only `user` re-renders on each `unread` change. type Session = { user: string; unread: number }; const SessionContext = createContext({ user: 'guest', unread: 0 }); @@ -295,11 +295,13 @@ function FilteredContacts({ items }: { items: Contact[] }) { // eager-list: showing the catalog mounts every tile at once instead of virtualizing the list. function EagerListScenario({ runId, onDone }: ScenarioProps) { - const [shown, setShown] = useState(false); - useBurst(runId, 1, 50, () => setShown((value) => !value), onDone); + const [mountedRun, setMountedRun] = useState(0); + useBurst(runId, 1, 50, () => setMountedRun(runId), onDone); return ( - {shown ? CATALOG.map((id) => ) : null} + {runId > 0 && mountedRun === runId + ? CATALOG.map((id) => ) + : null} ); }