Skip to content

Commit 92ca86d

Browse files
committed
Enhancement: Refactor GridRowScrollPinning to Hybrid rAF Engine (#9391)
- Rewrote GridRowScrollPinning to use a continuous requestAnimationFrame loop driven by native scroll events to achieve perfect 60fps syncing against the massive DOM layer. - VDOM delta updates now only modify the internal baseline state without touching the DOM. - Implemented hysteresis logic (isPinned) to maintain stable pinning states. - Reverted the addon registration back to the afterSetMounted lifecycle hook, as it now requires DOM node access for event listeners.
1 parent b7d6e45 commit 92ca86d

2 files changed

Lines changed: 118 additions & 56 deletions

File tree

src/grid/ScrollManager.mjs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,11 @@ class ScrollManager extends Base {
8787
*/
8888
afterSetMounted(value, oldValue) {
8989
if (value) {
90-
this.dragScroll && this.updateDragScrollAddon(true)
90+
this.dragScroll && this.updateDragScrollAddon(true);
91+
this.rowScrollPinning && this.updateRowScrollPinningAddon(true)
9192
} else if (oldValue) {
92-
this.updateDragScrollAddon(false)
93+
this.updateDragScrollAddon(false);
94+
this.updateRowScrollPinningAddon(false)
9395
}
9496
}
9597

@@ -98,7 +100,9 @@ class ScrollManager extends Base {
98100
* @param {Boolean} oldValue
99101
*/
100102
afterSetRowScrollPinning(value, oldValue) {
101-
this.updateRowScrollPinningAddon(value)
103+
if (this.mounted) {
104+
this.updateRowScrollPinningAddon(value)
105+
}
102106
}
103107

104108
/**
Lines changed: 111 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import Base from './Base.mjs';
22
import DomAccess from '../DomAccess.mjs';
33

4-
const translateRegex = /translate3d\(0px,\s*(-?\d+(?:\.\d+)?)px,\s*0px\)/;
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

Comments
 (0)