TableLayout.isStickyColumn() never sets allowOverflow, so a sticky column can't actually stick during virtualized horizontal scroll
- Dominant language
- TypeScript
- Stars
- 15.9k
- Forks
- 1.6k
- Avg merge
- 3d 9m
- Merged PRs (30d)
- 59
Description
## `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`):
```js
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:221` — `layoutNode.layoutInfo.allowOverflow = true;` in `buildChild`, unconditionally, for every item.
- `GridLayout.mjs:106` — `layoutInfo.allowOverflow = true;`, unconditionally, for every item.
- `WaterfallLayout.mjs:90` — `layoutInfo.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
```tsx
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 (
Name
Type
Opened
Balance
{(row) => (
{row.name}
{row.type}
{row.opened}
{row.balance}
)}
);
}
```
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.
Contributor guide
Research direction
Start with react-stately/private/layout/TableLayout.mjs:232 and compare its layout-node construction with ListLayout.mjs, GridLayout.mjs, and WaterfallLayout.mjs. Then read react-aria's VirtualizerItem.mjs layoutInfoToStyle and reproduce the issue by checking the sticky header's bounding rect after horizontal scrolling. Done means a column selected by isStickyColumn() remains visually pinned during virtualized horizontal scroll without requiring the consumer workaround.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100