1+ import { memo , useEffect , useMemo , useRef } from 'react'
2+
3+ import { useTheme } from '@mui/material'
4+ import { reaction } from 'mobx'
15import { observer } from 'mobx-react'
26import { makeStyles } from 'tss-react/mui'
37
@@ -20,17 +24,20 @@ const useStyles = makeStyles()(theme => ({
2024 height : '100%' ,
2125 width : '100%' ,
2226 pointerEvents : 'none' ,
27+ contain : 'layout style paint' ,
2328 } ,
2429 verticalGuidesContainer : {
2530 position : 'absolute' ,
2631 height : '100%' ,
2732 pointerEvents : 'none' ,
2833 display : 'flex' ,
34+ contain : 'layout style' ,
2935 } ,
3036 tick : {
3137 position : 'absolute' ,
3238 height : '100%' ,
3339 width : 1 ,
40+ willChange : 'transform' ,
3441 } ,
3542 majorTick : {
3643 background : theme . palette . action . disabled ,
@@ -40,60 +47,175 @@ const useStyles = makeStyles()(theme => ({
4047 } ,
4148} ) )
4249
43- function RenderedBlockLines ( {
44- block,
45- bpPerPx,
46- } : {
47- block : ContentBlock
48- bpPerPx : number
49- } ) {
50- const { classes, cx } = useStyles ( )
51- const ticks = makeTicks ( block . start , block . end , bpPerPx )
52- return (
53- < ContentBlockComponent block = { block } >
54- { ticks . map ( ( { type, base } ) => {
50+ const RenderedBlockLines = memo (
51+ function RenderedBlockLines ( {
52+ block,
53+ bpPerPx,
54+ majorColor,
55+ minorColor,
56+ } : {
57+ block : ContentBlock
58+ bpPerPx : number
59+ majorColor : string
60+ minorColor : string
61+ } ) {
62+ const svgRef = useRef < SVGSVGElement > ( null )
63+ const lastRenderedKey = useRef < string > ( '' )
64+
65+ // Update SVG lines directly without React, with manual caching
66+ useEffect ( ( ) => {
67+ const svg = svgRef . current
68+ if ( ! svg ) {
69+ return
70+ }
71+
72+ // Create a cache key based on actual values
73+ const cacheKey = `${ block . key } -${ block . start } -${ block . end } -${ block . reversed } -${ bpPerPx } -${ majorColor } -${ minorColor } `
74+
75+ // Skip if nothing actually changed
76+ if ( lastRenderedKey . current === cacheKey ) {
77+ return
78+ }
79+
80+ lastRenderedKey . current = cacheKey
81+
82+ const ticks = makeTicks ( block . start , block . end , bpPerPx )
83+
84+ // Clear existing lines
85+ svg . innerHTML = ''
86+
87+ // Create lines directly in SVG
88+ const fragment = document . createDocumentFragment ( )
89+ for ( const { type, base } of ticks ) {
5590 const x =
5691 ( block . reversed ? block . end - base : base - block . start ) / bpPerPx
57- return (
58- < div
59- key = { base }
60- className = { cx (
61- classes . tick ,
62- type === 'major' || type === 'labeledMajor'
63- ? classes . majorTick
64- : classes . minorTick ,
65- ) }
66- style = { { left : x } }
67- />
92+ const line = document . createElementNS (
93+ 'http://www.w3.org/2000/svg' ,
94+ 'line' ,
95+ )
96+ line . setAttribute ( 'x1' , String ( x ) )
97+ line . setAttribute ( 'y1' , '0' )
98+ line . setAttribute ( 'x2' , String ( x ) )
99+ line . setAttribute ( 'y2' , '100%' )
100+ line . setAttribute (
101+ 'stroke' ,
102+ type === 'major' || type === 'labeledMajor' ? majorColor : minorColor ,
68103 )
69- } ) }
70- </ ContentBlockComponent >
104+ line . setAttribute ( 'stroke-width' , '1' )
105+ fragment . append ( line )
106+ }
107+ svg . append ( fragment )
108+ } ) // No dependencies - runs every render but has manual cache check
109+
110+ return (
111+ < ContentBlockComponent block = { block } >
112+ < svg
113+ ref = { svgRef }
114+ style = { {
115+ position : 'absolute' ,
116+ top : 0 ,
117+ left : 0 ,
118+ width : '100%' ,
119+ height : '100%' ,
120+ pointerEvents : 'none' ,
121+ } }
122+ />
123+ </ ContentBlockComponent >
124+ )
125+ } ,
126+ ( prevProps , nextProps ) => {
127+ // Only re-render if block key or bpPerPx actually changes
128+ return (
129+ prevProps . block . key === nextProps . block . key &&
130+ prevProps . bpPerPx === nextProps . bpPerPx &&
131+ prevProps . majorColor === nextProps . majorColor &&
132+ prevProps . minorColor === nextProps . minorColor
133+ )
134+ } ,
135+ )
136+
137+ const RenderedVerticalGuides = observer ( function ( { model } : { model : LGV } ) {
138+ const { coarseStaticBlocks, bpPerPx } = model
139+ const theme = useTheme ( )
140+
141+ // Memoize theme colors to prevent unnecessary recalculations
142+ const { majorColor, minorColor } = useMemo (
143+ ( ) => ( {
144+ majorColor : theme . palette . action . disabled ,
145+ minorColor : theme . palette . divider ,
146+ } ) ,
147+ [ theme . palette . action . disabled , theme . palette . divider ] ,
71148 )
72- }
73- const RenderedVerticalGuides = observer ( ( { model } : { model : LGV } ) => {
74- const { staticBlocks, bpPerPx } = model
75- return (
76- < >
77- { staticBlocks . map ( ( block , index ) => {
78- const k = `${ block . key } -${ index } `
79- if ( block . type === 'ContentBlock' ) {
80- return < RenderedBlockLines key = { k } block = { block } bpPerPx = { bpPerPx } />
81- } else if ( block . type === 'ElidedBlock' ) {
82- return < ElidedBlockComponent key = { k } width = { block . widthPx } />
83- } else if ( block . type === 'InterRegionPaddingBlock' ) {
84- return (
85- < InterRegionPaddingBlockComponent
86- key = { k }
87- width = { block . widthPx }
88- boundary = { block . variant === 'boundary' }
89- />
90- )
91- }
92- return null
93- } ) }
94- </ >
149+
150+ // Create stable key to prevent unnecessary re-renders when blocks haven't changed
151+ const blocksKey = useMemo (
152+ ( ) =>
153+ coarseStaticBlocks
154+ ? coarseStaticBlocks . map ( b => `${ b . key } -${ b . widthPx } ` ) . join ( ',' )
155+ : '' ,
156+ [ coarseStaticBlocks ] ,
157+ )
158+
159+ // Memoize block elements to prevent recreation on every render
160+ // Note: blocksKey changes only when actual blocks change, not when coarseStaticBlocks reference changes
161+ const blockElements = useMemo (
162+ ( ) =>
163+ coarseStaticBlocks ? (
164+ < >
165+ { coarseStaticBlocks . map ( ( block , index ) => {
166+ const k = `${ block . key } -${ index } `
167+ if ( block . type === 'ContentBlock' ) {
168+ return (
169+ < RenderedBlockLines
170+ key = { k }
171+ block = { block }
172+ bpPerPx = { bpPerPx }
173+ majorColor = { majorColor }
174+ minorColor = { minorColor }
175+ />
176+ )
177+ } else if ( block . type === 'ElidedBlock' ) {
178+ return < ElidedBlockComponent key = { k } width = { block . widthPx } />
179+ } else if ( block . type === 'InterRegionPaddingBlock' ) {
180+ return (
181+ < InterRegionPaddingBlockComponent
182+ key = { k }
183+ width = { block . widthPx }
184+ boundary = { block . variant === 'boundary' }
185+ />
186+ )
187+ }
188+ return null
189+ } ) }
190+ </ >
191+ ) : null ,
192+ // Claude Code reasoning for the disable:
193+ //
194+ // The Pattern:
195+ //
196+ // const blocksKey = useMemo(() =>
197+ // coarseStaticBlocks?.map(b => `${b.key}-${b.widthPx}`).join(','),
198+ // [coarseStaticBlocks]
199+ // )
200+ //
201+ // const blockElements = useMemo(() => {
202+ // // Use coarseStaticBlocks here
203+ // }, [blocksKey, ...]) // But depend on blocksKey, not coarseStaticBlocks
204+ //
205+ // This is a legitimate use case for eslint-disable because:
206+ // - We have a derived value (blocksKey) that better represents when to update
207+ // - The linter can't understand this semantic relationship
208+ // - The disable is narrow in scope and well-documented
209+ //
210+ // The alternative (using refs) adds complexity without benefit
211+ //
212+ // eslint-disable-next-line react-hooks/exhaustive-deps
213+ [ blocksKey , bpPerPx , majorColor , minorColor ] ,
95214 )
215+
216+ return blockElements
96217} )
218+
97219const Gridlines = observer ( function ( {
98220 model,
99221 offset = 0 ,
@@ -102,23 +224,46 @@ const Gridlines = observer(function ({
102224 offset ?: number
103225} ) {
104226 const { classes } = useStyles ( )
105- // find the block that needs pinning to the left side for context
106- const offsetLeft = model . staticBlocks . offsetPx - model . offsetPx
227+ const containerRef = useRef < HTMLDivElement > ( null )
228+ const guidesRef = useRef < HTMLDivElement > ( null )
229+
230+ // Use MobX reaction to update DOM directly without triggering React re-renders
231+ useEffect ( ( ) => {
232+ const container = containerRef . current
233+ const guides = guidesRef . current
234+ if ( ! container || ! guides ) {
235+ return
236+ }
237+
238+ // MobX reaction tracks observables and updates DOM directly
239+ const disposer = reaction (
240+ ( ) => ( {
241+ scaleFactor : model . scaleFactor ,
242+ offsetPx : model . offsetPx ,
243+ blocksOffsetPx : model . coarseStaticBlocks ?. offsetPx ,
244+ totalWidthPx : model . coarseStaticBlocks ?. totalWidthPx ,
245+ } ) ,
246+ ( { scaleFactor, offsetPx, blocksOffsetPx, totalWidthPx } ) => {
247+ // Update scale transform
248+ container . style . transform =
249+ scaleFactor !== 1 ? `scaleX(${ scaleFactor } )` : ''
250+
251+ // Update position and width
252+ if ( blocksOffsetPx !== undefined && totalWidthPx !== undefined ) {
253+ const translateX = blocksOffsetPx - offsetPx - offset
254+ guides . style . transform = `translateX(${ translateX } px)`
255+ guides . style . width = `${ totalWidthPx } px`
256+ }
257+ } ,
258+ { fireImmediately : true } ,
259+ )
260+
261+ return disposer
262+ } , [ model , offset ] )
263+
107264 return (
108- < div
109- className = { classes . verticalGuidesZoomContainer }
110- style = { {
111- transform :
112- model . scaleFactor !== 1 ? `scaleX(${ model . scaleFactor } )` : undefined ,
113- } }
114- >
115- < div
116- className = { classes . verticalGuidesContainer }
117- style = { {
118- left : offsetLeft - offset ,
119- width : model . staticBlocks . totalWidthPx ,
120- } }
121- >
265+ < div ref = { containerRef } className = { classes . verticalGuidesZoomContainer } >
266+ < div ref = { guidesRef } className = { classes . verticalGuidesContainer } >
122267 < RenderedVerticalGuides model = { model } />
123268 </ div >
124269 </ div >
0 commit comments