Skip to content

Commit 3eb5a4a

Browse files
authored
test(ActionList): add parent re-render stress test
1 parent d97ff12 commit 3eb5a4a

File tree

1 file changed

+139
-0
lines changed

1 file changed

+139
-0
lines changed

packages/react/src/ActionList/ActionList.stress.dev.stories.tsx

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type {ComponentProps} from '../utils/types'
33
import {StressTest} from '../utils/StressTest'
44
import {TableIcon} from '@primer/octicons-react'
55
import {ActionList} from '.'
6+
import React, {useState, useEffect, useRef, useCallback, memo} from 'react'
7+
import afterFrame from 'afterframe'
68

79
export default {
810
title: 'StressTests/Components/ActionList',
@@ -48,3 +50,140 @@ export const SingleSelect = () => {
4850
/>
4951
)
5052
}
53+
54+
export const ParentRerender = () => {
55+
return <ContextMemoizationBenchmark />
56+
}
57+
58+
/**
59+
* This benchmark isolates the effect of context value memoization.
60+
*
61+
* Setup: A parent component holds a counter that increments 100 times.
62+
* The ActionList is rendered via a memoized child component (MemoizedList),
63+
* so React only re-renders it if props or context change.
64+
*
65+
* - Without useMemo on ListContext/ItemContext: the context values are new
66+
* objects every render, so React re-renders all context consumers (every
67+
* Description, LeadingVisual, TrailingVisual, Selection in every Item).
68+
* - With useMemo: context values are referentially stable, React bails out
69+
* of re-rendering the consumers entirely.
70+
*/
71+
72+
const listItems = Array.from({length: 100}, (_, i) => ({
73+
name: `Project ${i + 1}`,
74+
scope: `Scope ${i + 1}`,
75+
}))
76+
77+
// Memoized list component: only re-renders when props change or context forces it
78+
const MemoizedList = memo(function MemoizedList() {
79+
return (
80+
<ActionList selectionVariant="single" showDividers role="menu" aria-label="Project">
81+
{listItems.map((project, index) => (
82+
<ActionList.Item key={index} role="menuitemradio" selected={index === 0} aria-checked={index === 0}>
83+
<ActionList.LeadingVisual>
84+
<TableIcon />
85+
</ActionList.LeadingVisual>
86+
{project.name}
87+
<ActionList.Description variant="block">{project.scope}</ActionList.Description>
88+
</ActionList.Item>
89+
))}
90+
</ActionList>
91+
)
92+
})
93+
94+
function ContextMemoizationBenchmark() {
95+
const totalIterations = 100
96+
const [count, setCount] = useState(0)
97+
const [running, setRunning] = useState(false)
98+
const [results, setResults] = useState<{median: number; average: number; min: number; max: number} | null>(null)
99+
const observerRef = useRef<{observer: PerformanceObserver; data: number[]} | null>(null)
100+
101+
useEffect(() => {
102+
const duration: number[] = []
103+
const obs = new PerformanceObserver(list => {
104+
for (const entry of list.getEntries()) {
105+
if (entry.entryType === 'measure' && entry.name === 'ctx-rerender') {
106+
duration.push(entry.duration)
107+
}
108+
}
109+
})
110+
obs.observe({entryTypes: ['measure']})
111+
observerRef.current = {data: duration, observer: obs}
112+
return () => obs.disconnect()
113+
}, [])
114+
115+
const start = useCallback(() => {
116+
if (observerRef.current) observerRef.current.data.length = 0
117+
setResults(null)
118+
setRunning(true)
119+
setCount(0)
120+
121+
let i = 0
122+
const interval = setInterval(() => {
123+
if (i < totalIterations - 1) {
124+
performance.mark('ctx-start')
125+
setCount(c => c + 1)
126+
afterFrame(() => {
127+
performance.mark('ctx-end')
128+
performance.measure('ctx-rerender', 'ctx-start', 'ctx-end')
129+
})
130+
i++
131+
} else {
132+
clearInterval(interval)
133+
setTimeout(() => {
134+
const durations = observerRef.current?.data ?? []
135+
const sorted = [...durations].sort((a, b) => a - b)
136+
setResults({
137+
median: sorted[Math.floor(sorted.length / 2)] ?? 0,
138+
average: durations.reduce((a, b) => a + b, 0) / durations.length,
139+
min: Math.min(...durations),
140+
max: Math.max(...durations),
141+
})
142+
setRunning(false)
143+
}, 100)
144+
}
145+
}, 10)
146+
}, [])
147+
148+
return (
149+
<div style={{padding: 16}}>
150+
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16}}>
151+
<div>
152+
<strong>ActionList Context Memoization</strong>
153+
<p style={{color: '#656d76', margin: '4px 0'}}>
154+
Parent re-renders 100x with a counter. ActionList is memoized, so only context changes trigger item
155+
re-renders. Tests whether ListContext/ItemContext values are stable.
156+
</p>
157+
</div>
158+
<button
159+
type="button"
160+
onClick={start}
161+
disabled={running}
162+
style={{
163+
padding: '8px 16px',
164+
background: running ? '#ccc' : '#1a7f37',
165+
color: 'white',
166+
border: 'none',
167+
borderRadius: 6,
168+
cursor: running ? 'default' : 'pointer',
169+
}}
170+
>
171+
{running ? `Running ${count}/${totalIterations}...` : results ? 'Re-run' : 'Start'}
172+
</button>
173+
</div>
174+
175+
{/* The counter forces this component to re-render, but MemoizedList should bail out if context is stable */}
176+
<div style={{display: 'none'}}>Counter: {count}</div>
177+
<MemoizedList />
178+
179+
{results && (
180+
<div style={{marginTop: 16, fontFamily: 'monospace', fontSize: 14}}>
181+
<strong>Median: {results.median.toFixed(2)}ms</strong>
182+
{' | '}Average: {results.average.toFixed(2)}ms{' | '}
183+
Min: {results.min.toFixed(2)}ms{' | '}
184+
Max: {results.max.toFixed(2)}ms
185+
</div>
186+
)}
187+
</div>
188+
)
189+
}

0 commit comments

Comments
 (0)