@@ -3,6 +3,8 @@ import type {ComponentProps} from '../utils/types'
33import { StressTest } from '../utils/StressTest'
44import { TableIcon } from '@primer/octicons-react'
55import { ActionList } from '.'
6+ import React , { useState , useEffect , useRef , useCallback , memo } from 'react'
7+ import afterFrame from 'afterframe'
68
79export 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