DataGrid: split scroll/scroll to position/should focus position handling into hooks#3986
DataGrid: split scroll/scroll to position/should focus position handling into hooks#3986
Conversation
| return scrollStateMap.get(gridRef) ?? initialScrollState; | ||
| }, [gridRef]); | ||
|
|
||
| return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); |
There was a problem hiding this comment.
Scroll state now uses useSyncExternalStore instead of 2xuseState+flushSync
| gridRef: React.RefObject<HTMLDivElement | null>; | ||
| selectedPosition: { idx: number; rowIdx: number }; | ||
| }) { | ||
| const shouldFocusPositionRef = useRef(false); |
There was a problem hiding this comment.
This used to be a useState, but an eslint rule complained about setting state in effect.
Strangely it did not complain when the same code was in DataGrid, maybe because DataGrid is too big and complicated so it confuses static analysis algos.
Though to make sure the effect works, I had to change the selectedPosition.idx dependency to selectedPosition.
But now we 1 fewer state that can trigger re-renders!
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3986 +/- ##
==========================================
+ Coverage 97.41% 97.45% +0.04%
==========================================
Files 37 38 +1
Lines 1470 1495 +25
Branches 473 481 +8
==========================================
+ Hits 1432 1457 +25
Misses 38 38
🚀 New features to boost your workflow:
|
| setScrollToPosition, | ||
| scrollToPositionElement: scrollToPosition && ( | ||
| <div | ||
| ref={(div) => { |
There was a problem hiding this comment.
before: useRef+useLayoutEffect
now: 1 function
Seems to work just as well.
Since it doesn't need any hooks anymore, I've also inlined the component
There was a problem hiding this comment.
So this works because we recreate ref callback on each render?
There was a problem hiding this comment.
Correct.
Although it might not work 100% well with auto-sized columns + smooth scrolling.
With smooth scrolling it may take >1 frame before the grid starts scrolling, so the condition passes immediately.
We can revisit later or I can revert. Not sure this worked well before either.
| return initialScrollState; | ||
| } | ||
|
|
||
| const scrollStateMap = new WeakMap<React.RefObject<HTMLDivElement | null>, ScrollState>(); |
There was a problem hiding this comment.
Is there a benefit of using a map instead of setting a local state inside the component?
There was a problem hiding this comment.
Are you suggesting we keep using useState instead of useSyncExternalStore?
There was a problem hiding this comment.
function Test() {
const [resolvers, setResolvers] = useState<PromiseWithResolvers<void>>(() =>
Promise.withResolvers()
);
return (
<>
<button type="button" onClick={() => setResolvers(Promise.withResolvers())}>
New promise
</button>
<button type="button" onClick={() => resolvers.resolve()}>
Resolve
</button>
<Suspense fallback="Loading...">
<Inner promise={resolvers.promise} />
</Suspense>
</>
);
}
function Inner({ promise }: { promise: Promise<void> }) {
useState(() => {
console.log('init state');
});
use(promise);
return 'ok';
}In this example the useState init function in <Inner> is called twice, then once more after the promise resolves. (multiply by two in StrictMode)
Once React commits a render tree to the DOM though, then the init function is not called again.
This also happens when the suspense is triggered deeper in the tree:
function Inner({ promise }: { promise: Promise<void> }) {
useState(() => {
console.log('init state');
});
// use(promise);
return (
<>
<InnerSide />
<InnerDeep promise={promise} />
</>
);
}
function subscribe() {
console.log('subscribe');
return () => {
console.log('unsubscribe');
};
}
function getSnapshot() {
console.log('get snapshot');
}
function InnerSide() {
useState(() => {
console.log('init state side');
});
useSyncExternalStore(subscribe, getSnapshot);
return 'side';
}
function InnerDeep({ promise }: { promise: Promise<void> }) {
useState(() => {
console.log('init state deep');
});
use(promise);
return 'ok';
}React only stops re-initializing states when it renders the tree under <Suspense> to the DOM (as I understand it).
So <DataGrid> could be affected if anything suspends the render tree it is in.
Also, subscribe is called just after useLayoutEffects, but it is not cleaned up if the component suspends again.
useTransition and useDeferredValue also trigger concurrent rendering which may affect all this.
All that to say... I guess it's slightly better to have a single WeakMap?
There was a problem hiding this comment.
Does this mean a single WeakMap would be more performant or prevent some edge cases?
There was a problem hiding this comment.
It's simple and potentially avoids states reinitialization due to suspense/activity.
How would you rather this be implemented?
There was a problem hiding this comment.
Not sure. Let's go with what we have.
| gridRef: React.RefObject<HTMLDivElement | null>; | ||
| activePosition: Position; | ||
| }) { | ||
| const shouldFocusPositionRef = useRef(false); |
There was a problem hiding this comment.
Is it better to use state? little concerned that we have to manually handle the dependencies in the effect.
There was a problem hiding this comment.
That's what tests are for!
We have a few tests that fail when the dependency is activePosition.idx instead of activePosition.
I think ref makes sense here.
There was a problem hiding this comment.
Ended up moving this into useActivePosition, and found a more elegant solution
amanmahajan7
left a comment
There was a problem hiding this comment.
A few more tiny comments
| } | ||
|
|
||
| useLayoutEffect(() => { | ||
| if (positionToFocus !== null) { |
There was a problem hiding this comment.
Do we not need to reset positionToFocus after focus?
There was a problem hiding this comment.
No, to avoid 1 re-render.
I've added a ref tho to avoid re-focusing elements after the layout effect re-mounts
There was a problem hiding this comment.
Should we add a test? I though we had test to prevent focus stealing
There was a problem hiding this comment.
We don't have any tests for suspense/activity.
I can add one if you want
There was a problem hiding this comment.
I think it is okay. I got little confused on why we were resetting the state previously. The previous deps were [shouldFocusPosition, activePositionIsRow, gridRef]) so it would rerun when activePositionIsRow is changed. But now we only have [positionToFocus, gridRef]) which is more robust
| return initialScrollState; | ||
| } | ||
|
|
||
| const scrollStateMap = new WeakMap<React.RefObject<HTMLDivElement | null>, ScrollState>(); |
There was a problem hiding this comment.
Does this mean a single WeakMap would be more performant or prevent some edge cases?
| setScrollToPosition, | ||
| scrollToPositionElement: scrollToPosition && ( | ||
| <div | ||
| ref={(div) => { |
There was a problem hiding this comment.
So this works because we recreate ref callback on each render?
| /** Function called whenever the active position is changed */ | ||
| onActivePositionChange?: Maybe<(args: PositionChangeArgs<NoInfer<R>, NoInfer<SR>>) => void>; | ||
| /** Callback triggered when the grid is scrolled */ | ||
| onScroll?: Maybe<(event: React.UIEvent<HTMLDivElement>) => void>; |
src/DataGrid.tsxis quite fat, so I'm trying to slim it down.This improves co-locality too!