11import Base from './Base.mjs' ;
22import DomAccess from '../DomAccess.mjs' ;
33
4- const translateRegex = / t r a n s l a t e 3 d \( 0 p x , \s * ( - ? \d + (?: \. \d + ) ? ) p x , \s * 0 p x \) / ;
5-
64/**
7- * @summary Main Thread Addon for High-Performance Grid Row Scroll Pinning.
5+ * @summary Main Thread Addon for High-Performance Grid Row Scroll Pinning (Hybrid Engine) .
86 *
9- * This addon intercepts VDOM deltas for grid rows just before they are applied to the DOM.
10- * It compares the `scrollTop` state of the App Worker (when the deltas were generated)
11- * against the actual, current `scrollTop` of the Grid Body in the Main Thread.
7+ * This addon uses a Hybrid rAF + Scroll Listener architecture to prevent visual
8+ * row tearing during rapid scrolling (e.g., dragging the scrollbar thumb).
129 *
13- * If there is a discrepancy (e.g., due to fast native scrolling), it surgically modifies
14- * the `translate3d` CSS transform values of the row deltas inline. This visually "pins"
15- * the rows to their correct physical position on the screen, completely eliminating
16- * scroll thrashing and white flashes during rapid scrolling.
10+ * **The Hybrid Architecture:**
11+ * 1. **Stateful Updates:** VDOM updates from the App Worker do NOT trigger DOM mutations.
12+ * They merely update an internal `workerScrollTop` state variable.
13+ * 2. **Scroll Driven:** A native `scroll` listener on the Grid Wrapper triggers the logic.
14+ * 3. **rAF Debouncing:** The scroll event schedules a single `requestAnimationFrame` callback.
15+ * 4. **Optical Pinning:** Inside the rAF loop, if the `deltaY` (Actual Scroll - Worker Scroll)
16+ * exceeds a large threshold (e.g. 30 rows), a CSS `translateY` is applied to the
17+ * *entire Grid Body Content Node*. This instantly slides the perfectly-laid-out pool
18+ * of rows to stay on screen, bypassing the 50ms+ VDOM worker latency.
1719 *
1820 * @class Neo.main.addon.GridRowScrollPinning
1921 * @extends Neo.main.addon.Base
@@ -39,21 +41,64 @@ class GridRowScrollPinning extends Base {
3941 }
4042
4143 /**
44+ * Stores state per registered Grid Body.
45+ * Shape: { id, bodyId, wrapperNode, contentNode, workerScrollTop, rowHeight, ticking }
4246 * @member {Map<String, Object>} registrations=new Map()
4347 * @protected
4448 */
4549 registrations = new Map ( )
4650
51+ /**
52+ * We bind the scroll handler once to avoid creating new functions per registration.
53+ * @member {Function} boundOnScroll
54+ * @protected
55+ */
56+ boundOnScroll = this . onScroll . bind ( this )
57+
4758 /**
4859 * @param {Object } config
4960 */
5061 construct ( config ) {
5162 super . construct ( config ) ;
52-
5363 Neo . main . DeltaUpdates . on ( 'update' , this . onDeltaUpdate , this )
5464 }
5565
5666 /**
67+ * Executes inside the rAF loop. Reads DOM, does math, mutates DOM.
68+ * @param {String } id
69+ * @protected
70+ */
71+ applyPinning ( id ) {
72+ let me = this ,
73+ state = me . registrations . get ( id ) ;
74+
75+ if ( ! state || ! state . wrapperNode || ! state . contentNode ) {
76+ if ( state ) state . ticking = false ;
77+ return
78+ }
79+
80+ let actualScrollTop = state . wrapperNode . scrollTop || 0 ,
81+ deltaY = actualScrollTop - state . workerScrollTop ,
82+ absDelta = Math . abs ( deltaY ) ;
83+
84+ // Hysteresis: If we are already pinned, stay pinned until the worker is within 2px.
85+ // If we are NOT pinned, engage pinning if the worker lags by more than 1 row.
86+ let shouldPin = state . isPinned ? ( absDelta > 2 ) : ( absDelta > state . rowHeight ) ;
87+
88+ if ( shouldPin ) {
89+ state . contentNode . style . transform = `translate3d(0px, ${ deltaY } px, 0px)` ;
90+ state . isPinned = true ;
91+ } else if ( state . contentNode . style . transform ) {
92+ // Worker has caught up to the scroll position. Clear the optical shift.
93+ state . contentNode . style . transform = null ;
94+ state . isPinned = false ;
95+ }
96+
97+ state . ticking = false
98+ }
99+
100+ /**
101+ * Updates internal baseline state. Does NOT mutate the DOM.
57102 * @param {Object } data The event payload from Neo.main.DeltaUpdates
58103 * @protected
59104 */
@@ -65,75 +110,88 @@ class GridRowScrollPinning extends Base {
65110 return
66111 }
67112
68- me . registrations . forEach ( registration => {
69- let bodyMeta = meta [ registration . bodyId ] ;
113+ me . registrations . forEach ( state => {
114+ let bodyMeta = meta [ state . bodyId ] ;
70115
71116 if ( bodyMeta ) {
72- // The actual scroll container in the DOM is the wrapper node
73- let wrapperId = registration . bodyId + '__wrapper' ,
74- actualScrollTop = DomAccess . getElement ( wrapperId ) ?. scrollTop || 0 ,
75- deltaY = actualScrollTop - bodyMeta . scrollTop ;
76-
77- // Engage pinning if the worker is off by more than 2 rows
78- if ( Math . abs ( deltaY ) > ( bodyMeta . rowHeight * 2 ) ) {
79- me . pinRows ( deltas , registration . bodyId , deltaY )
117+ // Silently update the baseline state. The rAF loop will consume this on the next scroll tick.
118+ state . workerScrollTop = bodyMeta . scrollTop ;
119+ state . rowHeight = bodyMeta . rowHeight ;
120+
121+ // CRITICAL: If the worker sends an update after the user stops scrolling,
122+ // we must re-evaluate to clear the stale transform!
123+ if ( ! state . ticking ) {
124+ state . ticking = true ;
125+ requestAnimationFrame ( ( ) => me . applyPinning ( state . id ) )
80126 }
81127 }
82128 } )
83129 }
84130
85131 /**
86- * Surgically modifies the translate3d string for row updates.
87- * @param {Object[] } deltas
88- * @param {String } bodyId
89- * @param {Number } deltaY
132+ * Triggered by native browser scroll. Debounces via rAF.
133+ * @param {Event } event
90134 * @protected
91135 */
92- pinRows ( deltas , bodyId , deltaY ) {
93- let i = 0 ,
94- len = deltas . length ,
95- rowIdRef = bodyId + '__row-' ,
96- delta , transformMatch , currentY ;
97-
98- for ( ; i < len ; i ++ ) {
99- delta = deltas [ i ] ;
100-
101- // In VDOM diffs, attribute updates often omit the 'action' property,
102- // relying on DeltaUpdates to default to 'updateNode'.
103- if (
104- ( ! delta . action || delta . action === 'updateNode' ) &&
105- delta . id ?. startsWith ( rowIdRef ) &&
106- delta . style ?. transform
107- ) {
108- transformMatch = delta . style . transform . match ( translateRegex ) ;
109-
110- if ( transformMatch && transformMatch [ 1 ] ) {
111- currentY = parseFloat ( transformMatch [ 1 ] ) ;
112- // Apply the inline mutation
113- delta . style . transform = `translate3d(0px, ${ currentY + deltaY } px, 0px)`
114- }
136+ onScroll ( event ) {
137+ let me = this ,
138+ wrapper = event . target ,
139+ state ;
140+
141+ // Find which registration this scroll event belongs to based on the wrapper node
142+ for ( const reg of me . registrations . values ( ) ) {
143+ if ( reg . wrapperNode === wrapper ) {
144+ state = reg ;
145+ break
115146 }
116147 }
148+
149+ if ( state && ! state . ticking ) {
150+ state . ticking = true ;
151+ requestAnimationFrame ( ( ) => me . applyPinning ( state . id ) )
152+ }
117153 }
118154
119155 /**
120- * Registers a grid for row scroll pinning.
156+ * Registers a grid for row scroll pinning and attaches native scroll listener .
121157 * @param {Object } data
122158 * @param {String } data.bodyId The ID of the grid body
123159 * @param {String } data.id Unique identifier for the registration (e.g. ScrollManager id)
124160 */
125161 register ( { bodyId, id} ) {
126- this . registrations . set ( id , { bodyId, id} )
162+ let me = this ,
163+ wrapperNode = DomAccess . getElement ( bodyId + '__wrapper' ) ,
164+ contentNode = DomAccess . getElement ( bodyId ) ;
165+
166+ if ( wrapperNode && contentNode ) {
167+ me . registrations . set ( id , {
168+ id,
169+ bodyId,
170+ wrapperNode,
171+ contentNode,
172+ rowHeight : 0 ,
173+ ticking : false ,
174+ workerScrollTop : 0
175+ } ) ;
176+
177+ wrapperNode . addEventListener ( 'scroll' , me . boundOnScroll , { passive : true } )
178+ }
127179 }
128180
129181 /**
130- * Unregisters a grid.
182+ * Unregisters a grid and cleans up listeners .
131183 * @param {Object } data
132184 * @param {String } data.id
133185 */
134186 unregister ( { id} ) {
135- this . registrations . delete ( id )
187+ let me = this ,
188+ state = me . registrations . get ( id ) ;
189+
190+ if ( state && state . wrapperNode ) {
191+ state . wrapperNode . removeEventListener ( 'scroll' , me . boundOnScroll ) ;
192+ me . registrations . delete ( id )
193+ }
136194 }
137195}
138196
139- export default Neo . setupClass ( GridRowScrollPinning ) ;
197+ export default Neo . setupClass ( GridRowScrollPinning ) ;
0 commit comments