adobe / adobe/react-spectrum

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

Open
#10,518 6 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.