Skip to content

fix: sync sticky head on body resize via ResizeObserver#50

Open
melikhov-dev wants to merge 5 commits intomainfrom
fix/sticky-head-resize-observer
Open

fix: sync sticky head on body resize via ResizeObserver#50
melikhov-dev wants to merge 5 commits intomainfrom
fix/sticky-head-resize-observer

Conversation

@melikhov-dev
Copy link
Copy Markdown

Problem

When a DataTable is rendered with stickyHead: MOVING and cell content changes asynchronously (e.g. via useEffect + dangerouslySetInnerHTML), the sticky header column widths become desynchronised from the body.

Root cause: syncHeadWidths() was only triggered by window.resize. An async re-render of <tbody> children doesn't fire TableHead.componentDidUpdate (head props/state didn't change) and doesn't dispatch a resize event, so the sticky head stays at the stale widths from the initial render.

The symptom persists until something causes a window.resize — e.g. toggling DevTools or calling window.dispatchEvent(new Event('resize')) manually.

Fix

Subscribe a ResizeObserver to _box in componentDidMount whenever stickyHead is enabled. When the observer fires, a requestAnimationFrame debounce coalesces rapid bursts into a single syncHeadWidths() call. Both the observer and any pending rAF are cleaned up in componentWillUnmount.

// componentDidMount
if (stickyHead && this._box && typeof ResizeObserver !== 'undefined') {
    this._bodyResizeObserver = new ResizeObserver(() => {
        if (this._syncHeadRaf) return;
        this._syncHeadRaf = requestAnimationFrame(() => {
            this._syncHeadRaf = 0;
            this.syncHeadWidths();
        });
    });
    this._bodyResizeObserver.observe(this._box);
}

// componentWillUnmount
this._bodyResizeObserver?.disconnect();
if (this._syncHeadRaf) cancelAnimationFrame(this._syncHeadRaf);

Compatibility: guarded by typeof ResizeObserver !== 'undefined' — no-op in SSR and older browsers.

Tests

Adds the first component-level test suite (src/test/DataTable.stickyHead.test.tsx, 8 tests):

  • ResizeObserver is created and observes .data-table__box when stickyHead is enabled
  • ResizeObserver is not created when stickyHead is disabled
  • disconnect() is called on unmount (no leak)
  • A resize event schedules exactly one rAF
  • Multiple rapid resize events coalesce into one rAF
  • The guard resets correctly after each rAF flush
  • No throw when ResizeObserver is unavailable

Also adds required test infrastructure: jest-environment-jsdom, @testing-library/react, @types/jest, SCSS mock, and updates jest.config.js to pick up .test.tsx files.

When cell content changes asynchronously (e.g. useEffect +
dangerouslySetInnerHTML), only the tbody re-renders. This does not
trigger TableHead.componentDidUpdate, and no window.resize is fired,
so the sticky head column widths stay stale.

Fix: subscribe a ResizeObserver to _box in componentDidMount when
stickyHead is enabled. Callbacks are debounced through a single
requestAnimationFrame so rapid bursts coalesce into one syncHeadWidths()
call. The observer is disconnected and the pending rAF is cancelled in
componentWillUnmount. Guarded by typeof ResizeObserver !== 'undefined'
for SSR / older browser compatibility.

Also adds the first component-level test suite for DataTable, covering:
- ResizeObserver is created and observes _box when stickyHead is on
- ResizeObserver is not created when stickyHead is off
- disconnect() is called on unmount (no leak)
- resize events are debounced into one rAF
- multiple rapid events coalesce into one rAF
- guard resets correctly after each rAF flush
- no throw when ResizeObserver is unavailable
@testing-library/react@16 requires @types/react@^18 which conflicts
with the project-pinned @types/react@16.14.21, causing npm ci to fail
on CI. Switch to the legacy ReactDOM.render / unmountComponentAtNode
API (already typed by @types/react-dom@16) and act from
react-dom/test-utils. React 18 deprecation warnings for those APIs are
suppressed inside the test file.
@types/react was pinned to 16.14.21 while react/react-dom were already
on ^18.3.1 (bumped in 5073030 without updating the type packages).
Align them so the types match the runtime.

This also unblocks @testing-library/react which requires
@types/react@^18, so the test file is switched back from the legacy
ReactDOM.render API to @testing-library/react render + act.
Previous lock file was generated with --legacy-peer-deps on Node 22,
causing npm ci to fail on CI (Node 18) with missing type-fest entry.
Regenerated cleanly after aligning @types/react to ^18.
@gravity-ui-bot
Copy link
Copy Markdown
Contributor

Preview is ready.

@melikhov-dev melikhov-dev requested a review from kuzmadom May 9, 2026 15:19
Restore lock file to main, then re-run npm install on top so the diff
only reflects the packages actually added in this PR (@types/react^18,
@types/react-dom^18, @testing-library/* and their transitive deps).
@melikhov-dev melikhov-dev requested a review from korvin89 May 9, 2026 15:27
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.

2 participants