Skip to content

DataGrid: split scroll/scroll to position/should focus position handling into hooks#3986

Merged
nstepien merged 18 commits intomainfrom
split-grid
Apr 23, 2026
Merged

DataGrid: split scroll/scroll to position/should focus position handling into hooks#3986
nstepien merged 18 commits intomainfrom
split-grid

Conversation

@nstepien
Copy link
Copy Markdown
Collaborator

@nstepien nstepien commented Feb 26, 2026

src/DataGrid.tsx is quite fat, so I'm trying to slim it down.
This improves co-locality too!

@nstepien nstepien self-assigned this Feb 26, 2026
return scrollStateMap.get(gridRef) ?? initialScrollState;
}, [gridRef]);

return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scroll state now uses useSyncExternalStore instead of 2xuseState+flushSync

Comment thread src/hooks/useShouldFocusPosition.ts Outdated
gridRef: React.RefObject<HTMLDivElement | null>;
selectedPosition: { idx: number; rowIdx: number };
}) {
const shouldFocusPositionRef = useRef(false);
Copy link
Copy Markdown
Collaborator Author

@nstepien nstepien Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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!

@nstepien nstepien marked this pull request as ready for review February 26, 2026 20:57
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Feb 27, 2026

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.45%. Comparing base (1e98765) to head (332d1dd).

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              
Files with missing lines Coverage Δ
src/DataGrid.tsx 98.85% <100.00%> (-0.07%) ⬇️
src/hooks/useActivePosition.ts 95.91% <100.00%> (+0.91%) ⬆️
src/hooks/useGridDimensions.ts 100.00% <100.00%> (ø)
src/hooks/useScrollState.ts 100.00% <100.00%> (ø)
src/hooks/useScrollToPosition.tsx 100.00% <100.00%> (ø)
src/utils/domUtils.ts 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

setScrollToPosition,
scrollToPositionElement: scrollToPosition && (
<div
ref={(div) => {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this works because we recreate ref callback on each render?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a benefit of using a map instead of setting a local state inside the component?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you suggesting we keep using useState instead of useSyncExternalStore?

Copy link
Copy Markdown
Collaborator Author

@nstepien nstepien Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean a single WeakMap would be more performant or prevent some edge cases?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's simple and potentially avoids states reinitialization due to suspense/activity.
How would you rather this be implemented?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure. Let's go with what we have.

Comment thread src/hooks/useShouldFocusPosition.ts Outdated
gridRef: React.RefObject<HTMLDivElement | null>;
activePosition: Position;
}) {
const shouldFocusPositionRef = useRef(false);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it better to use state? little concerned that we have to manually handle the dependencies in the effect.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ended up moving this into useActivePosition, and found a more elegant solution

Comment thread src/DataGrid.tsx Outdated
@nstepien nstepien requested a review from amanmahajan7 April 7, 2026 18:46
Copy link
Copy Markdown
Collaborator

@amanmahajan7 amanmahajan7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few more tiny comments

Comment thread src/hooks/useActivePosition.ts Outdated
}

useLayoutEffect(() => {
if (positionToFocus !== null) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we not need to reset positionToFocus after focus?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, to avoid 1 re-render.
I've added a ref tho to avoid re-focusing elements after the layout effect re-mounts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a test? I though we had test to prevent focus stealing

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have any tests for suspense/activity.
I can add one if you want

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean a single WeakMap would be more performant or prevent some edge cases?

setScrollToPosition,
scrollToPositionElement: scrollToPosition && (
<div
ref={(div) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this works because we recreate ref callback on each render?

Comment thread src/DataGrid.tsx
/** 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>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@nstepien nstepien merged commit 1dcce2a into main Apr 23, 2026
2 checks passed
@nstepien nstepien deleted the split-grid branch April 23, 2026 16:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants