Skip to content

TableLayout.isStickyColumn() never sets allowOverflow, so a sticky column can't actually stick during virtualized horizontal scroll #10518

Description

@aaronmars

TableLayout.isStickyColumn() never sets allowOverflow, so a sticky column can't actually stick during virtualized horizontal scroll

Versions

  • react-aria-components: 1.18.0
  • react-stately: 3.47.0
  • react-aria: 3.49.0
  • react: 18.3.1

Summary

TableLayout (react-stately/private/layout/TableLayout) exposes isStickyColumn() as a protected, subclass-only extension point — overriding it does get LayoutInfo.isSticky/zIndex set correctly, and keeps that column's cells mounted across virtualization's scroll-driven mount/unmount cycle (stickyColumnIndices, used by persistedIndices). But it never also sets LayoutInfo.allowOverflow, which VirtualizerItem's layoutInfoToStyle needs to actually render the sticky cell so it isn't clipped by its own absolutely-positioned virtualizer wrapper. The result: position: sticky is applied to the DOM node and looks correct in a computed-style check, but the column still visibly scrolls out of view with the rest of the row on a real horizontal scroll.

Every other spatial Layout class in the same package (ListLayout, GridLayout, WaterfallLayout) sets layoutInfo.allowOverflow = true unconditionally on every item it builds — so any of their own items that also happen to be sticky (e.g. ListLayout's sticky section headers) are never clipped by this. TableLayout sets allowOverflow on no node, anywhere.

Root cause, with source references

VirtualizerItem.mjs's layoutInfoToStyle (react-aria):

top: layoutInfo.rect.y - (parent && !(parent.allowOverflow && layoutInfo.isSticky) ? parent.rect.y : 0),
[xProperty]: layoutInfo.rect.x - (parent && !(parent.allowOverflow && layoutInfo.isSticky) ? parent.rect.x : 0),
...
position: layoutInfo.isSticky ? 'sticky' : 'absolute',
...
overflow: layoutInfo.allowOverflow ? 'visible' : 'hidden',

(There's even an existing // TODO: ... quite ambiguous ... comment right above this block acknowledging the sticky/overflow interaction here isn't fully settled.)

So a sticky node's own x/top is only computed relative to the real (unscrolled) coordinate space when both layoutInfo.isSticky and its parent's allowOverflow are true. Otherwise it's offset by the parent's own (already-scrolled) position — which defeats position: sticky entirely, since the parent itself is overflow: hidden and moves with the scroll.

TableLayout.isStickyColumn() (react-stately/private/layout/TableLayout.mjs:232) only feeds isSticky/zIndex (:220-221, :338-339) and stickyColumnIndices (:78, used for persistedIndices at :417/:463-465). Grepping the entire file for allowOverflow returns zero matches — no method in TableLayout ever sets it, for any node.

Contrast the siblings:

  • ListLayout.mjs:221layoutNode.layoutInfo.allowOverflow = true; in buildChild, unconditionally, for every item.
  • GridLayout.mjs:106layoutInfo.allowOverflow = true;, unconditionally, for every item.
  • WaterfallLayout.mjs:90layoutInfo.allowOverflow = true;, unconditionally, for every item.

TableLayout is the outlier: it has the machinery to mark a node sticky (isStickyColumn) but not the matching machinery to let that stickiness actually render.

Reproduction

import { Virtualizer, TableLayout, Table, TableHeader, Column, TableBody, Row, Cell } from 'react-aria-components';

class StickyFirstColumnLayout extends TableLayout {
  isStickyColumn(node) {
    return node.column?.key === 'name';
  }
}

function App() {
  return (
    <div style={{ height: 400, width: 400, overflow: 'auto' }}>
      <Virtualizer layout={StickyFirstColumnLayout} layoutOptions={{ estimatedRowHeight: 40 }}>
        <Table aria-label="Accounts">
          <TableHeader>
            <Column key="name" isRowHeader>Name</Column>
            <Column key="type">Type</Column>
            <Column key="opened">Opened</Column>
            <Column key="balance">Balance</Column>
          </TableHeader>
          <TableBody items={rows /* wide enough that the columns overflow horizontally */}>
            {(row) => (
              <Row>
                <Cell>{row.name}</Cell>
                <Cell>{row.type}</Cell>
                <Cell>{row.opened}</Cell>
                <Cell>{row.balance}</Cell>
              </Row>
            )}
          </TableBody>
        </Table>
      </Virtualizer>
    </div>
  );
}
  1. getComputedStyle(nameColumnHeader).position'sticky' (looks correct).
  2. Scroll the table horizontally (scrollLeft = 150 on the scrolling ancestor).
  3. nameColumnHeader.getBoundingClientRect().left moves by (roughly) the scrolled distance — the column visibly scrolls away instead of staying pinned. A computed-style assertion alone will not catch this; only checking the real bounding rect after a real scroll does.

Expected behavior

A column marked sticky via isStickyColumn() stays visually pinned during horizontal scroll, the same way ListLayout's sticky section headers do (which stay pinned because their allowOverflow chain is intact by virtue of every item getting allowOverflow = true).

Suggested fix

Either:

  1. TableLayout sets allowOverflow = true on the ancestor chain (header/header-row/row/row-group layout nodes) whenever any of its descendant columns is sticky — mirroring what the sibling layouts already do unconditionally, just scoped to when it's actually needed; or
  2. At minimum, document that isStickyColumn() alone is insufficient, and that a real fix requires a consumer to also override buildTableHeader/buildHeaderRow/buildRow/buildRowGroup to set allowOverflow on every ancestor between the sticky node and the real scrollport.

Current workaround

We ship a subclass doing exactly that — overriding isStickyColumn() plus buildTableHeader/buildHeaderRow/buildRow/buildRowGroup to set allowOverflow = true on each. Happy to share it if useful as a starting point for a fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions