Skip to content

feat(TreeView): add opt-in virtualization via height prop#2475

Open
mateoviilla1 wants to merge 6 commits into
developfrom
feat/treeview-virtualization
Open

feat(TreeView): add opt-in virtualization via height prop#2475
mateoviilla1 wants to merge 6 commits into
developfrom
feat/treeview-virtualization

Conversation

@mateoviilla1

Copy link
Copy Markdown
Contributor

Summary

Adds opt-in virtualization to TreeView via a new height prop.

Long lists kept every row mounted and re-rendered all of them on each
selection change, so a single checkbox click could freeze for ~1s while
react-aria reconciled the whole tree (surfaced downstream in axe-reports).

When height is set to a non-zero value, the tree becomes a fixed-height
scroll region wrapped in react-aria's Virtualizer + ListLayout, so only the
rows in view are rendered and selection stays fast regardless of list length.
When height is omitted (or 0), the tree grows to fit all rows exactly as
before — the non-virtualized path is unchanged.

<TreeView height={480} items={items} selectionMode="multiple" />

Details

  • ListLayout gap/padding mirror the .TreeView --space-quarter (2px) /
    --space-half (4px) tokens, so virtualized and non-virtualized rows are
    spaced identically.
  • Caller-supplied style is merged with the height (height wins) rather than
    clobbering it or being clobbered by the ...other passthrough.
  • height is documented as a stable prop — toggling it at runtime remounts
    the underlying tree (resets uncontrolled expansion/scroll/focus).
  • New .TreeView--virtualized neutralizes flex/gap/container padding, since
    virtualized rows are absolutely positioned and spacing comes from the layout.
  • Composes with the existing cascadeSelect/cascadeDeselect (covered by a
    test + docs example).

Testing

  • pnpm --filter @deque/cauldron-react test — 39 TreeView tests pass, incl.
    virtualized render/selection, zero-height fallback, style merge, virtualized
    cascade, and a jest-axe check. Existing snapshots unchanged.
  • Docs example added under TreeView → Virtualized (Long Lists).
  • Adds a virtualized e2e screenshot case — baselines to be generated via the
    update-screenshots label.

QA notes: exercise a long tree with height set — scrolling windows rows, and
checkbox clicks toggle instantly; confirm a tree without height is unchanged.

Ref dequelabs/axe-reports#3277

Long TreeView lists kept every row mounted and re-rendered all of them on
each selection change, so a single checkbox click could freeze for ~1s while
react-aria reconciled the whole list.

Add an opt-in `height` prop: when set to a non-zero value the tree becomes a
fixed-height scroll region wrapped in react-aria's Virtualizer + ListLayout,
so only the rows in view are rendered and selection changes stay fast. When
omitted (or 0) the tree grows to fit all rows exactly as before — the
non-virtualized path is unchanged.

- ListLayout gap/padding mirror the .TreeView --space-quarter/--space-half
  tokens so virtualized and non-virtualized rows are spaced identically
- caller-supplied style is merged with the height (height wins) rather than
  clobbering it
- .TreeView--virtualized neutralizes flex/gap/container padding since rows are
  absolutely positioned and spacing comes from the layout options
- docs + unit tests (incl. virtualized cascade selection) + e2e screenshot

Ref dequelabs/axe-reports#3277
@mateoviilla1
mateoviilla1 requested review from a team as code owners July 15, 2026 16:13
Copilot AI review requested due to automatic review settings July 15, 2026 16:13
@mateoviilla1 mateoviilla1 added the update-screenshots Trigger CI to regenerate screenshot baselines label Jul 15, 2026
@github-actions github-actions Bot removed the update-screenshots Trigger CI to regenerate screenshot baselines label Jul 15, 2026
@aws-amplify-us-east-1

Copy link
Copy Markdown

This pull request is automatically being deployed by Amplify Hosting (learn more).

Access this pull request here: https://pr-2475.d15792l1n26ww3.amplifyapp.com

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds opt-in virtualization to TreeView via a new height prop, aiming to keep selection interactions responsive for very large trees by only rendering rows in view while preserving existing “grow-to-fit” behavior when height is omitted (or 0).

Changes:

  • Introduces height?: number | string to enable a virtualized rendering path using react-aria-components Virtualizer + ListLayout.
  • Adds .TreeView--virtualized styling to neutralize non-virtualized layout (flex/gap/padding) so spacing comes from the virtualizer layout options.
  • Expands automated coverage with new unit tests, docs examples, and an e2e screenshot case for the virtualized mode.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/styles/tree-view.css Adds a virtualized modifier class to adjust layout behavior for absolutely positioned virtual rows.
packages/react/src/components/TreeView/index.tsx Implements opt-in virtualization via height and merges height with caller style.
packages/react/src/components/TreeView/TreeView.test.tsx Adds unit tests for virtualization activation, height behavior, style merge, a11y, and cascade selection.
packages/react/src/components/TreeView/screenshots.e2e.tsx Adds a new visual regression case for a long, virtualized TreeView in light/dark themes.
docs/pages/components/TreeView.mdx Documents virtualized usage and adds height to the props table plus examples.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/react/src/components/TreeView/index.tsx Outdated
Comment thread docs/pages/components/TreeView.mdx Outdated
…aselines

- type TreeViewProps with React.HTMLAttributes<HTMLDivElement> so style/
  data-*/handlers are typed (matches ProgressBar); drop the style cast in the
  component and the object cast in the style-merge test
- clarify the height prop docs: 0 is treated as unset (non-virtualized)
- fix the virtualized e2e case: the screenshot harness sizes the mounted root
  to content (min-height/max-width: max-content on #root > div), which
  overrode the fixed height and collapsed the absolutely-positioned virtual
  rows; wrap in a div + give the tree an explicit width so it renders the
  fixed-height scroll region as in a real app
- add the tree-view-virtualized light/dark baselines (only the ~8 visible rows
  of a 200-item list render, confirming virtualization windows correctly)

Ref dequelabs/axe-reports#3277
@mateoviilla1
mateoviilla1 requested a review from a team as a code owner July 15, 2026 16:40
@mateoviilla1

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in d14b8cc:

Copilot comments

  • Typed propsTreeViewProps now extends React.HTMLAttributes<HTMLDivElement> (matching ProgressBar), so style / data-* / event handlers are typed. Dropped the internal style cast and the as object cast in the test.
  • height={0} docs — updated the prop description to state that 0 is treated as unset (non-virtualized), matching the implementation.

Screenshots failure
The update-screenshots label job silently committed nothing because its git diff --quiet check ignores untracked files (our baselines are brand-new), so the comparison job then failed on the missing snapshot.

While generating the baseline I found the virtualized screenshot rendered the full 200-row tree instead of a clipped scroll region. Root cause was the CT harness template (playwright/index.html) applying min-height/max-width: max-content to #root > div, which overrode the fixed height and collapsed the absolutely-positioned virtual rows on a top-level mounted component (a plain <div style="height:240;overflow:auto"> with 200 children reproduced it — not a component bug). Fixed the e2e case by wrapping the tree in a div and giving it an explicit width; it now renders the fixed-height scroll region correctly. The committed baseline shows only the ~8 visible rows of a 200-item list, confirming virtualization windows as intended.

@mateoviilla1
mateoviilla1 requested a review from markreay July 15, 2026 16:47
@rheisler-deque
rheisler-deque self-requested a review July 20, 2026 18:44

@rheisler-deque rheisler-deque left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for submitting this PR to add this feature to Cauldron. I have tested the virtualization functionality in the Amplitude build, and the component is definitely only rendering a small number of rows near the visible set.

This would be the first example of virtualization in Cauldron. We try to make sure Cauldron components are accessible and useful in a general sense, not just to solve a specific problem that one consumer needs to solve. I think adding virtualization to the TreeView is reasonable because it's likely to have an issue with virtualization for any consumer. Virtualization in the TreeView could serve as a model for other components that need it, and later be generalized to wrap any component if needed.

However, I don't think this implementation is ready to be integrated into Cauldron, which will affect all consumers including those outside Deque. I've noted a potential major accessibility issue below, and it's missing features most consumers need when virtualizing a large list of elements. This PR sets the pattern for virtualization we'll use in other elements in Cauldron when more virtualization is needed, so I want to make sure we identify the gaps and make a plan to fill them before this gets merged.

Accessibility Concern

Elements that are removed from the DOM are also removed from the accessibility tree. That means a user exploring the TreeView with a screen reader may not be able to perceive all the elements without scrolling them into view. Right now, you can scroll elements into view with the Up, Down, PageUp, and PageDown keys, but assistive technologies change key behavior, so we would need to verify that they still work with all major screen readers active, and that mobile screen reader users can navigate the list.

Assistive Technology users often explore a page by looking at all the elements of a certain type. JAWS for example, has a view to show you all the buttons, all the links, all the list items, etc. TalkBack on Android lets you navigate from element to element of a specific type, but only for elements that are already rendered. I believe a user doing either of these things would only perceive the elements of the TreeView that were rendered when they arrived on the page, and I'm not sure they would have a way to actually reach them.

In this implementation, they do have a way to perceive that there are unrendered elements, which is very good. In virtualized mode the rows keep correct aria-level, aria-posinset, and aria-setsize. A child row still reports posinset=N setsize=200, so I'm pretty sure it would announce "item 51 of 200" if there were 200 items in the tree.

Upstream Issue

In virtualized mode the treegrid gains aria-rowcount set to the full row total, but the rows never get a matching aria-rowindex. When a grid or treegrid sets aria-rowcount because the DOM holds only a subset of rows, each rendered row is supposed to carry aria-rowindex so assistive technology can place it in the full set. That pairing is missing here.

The cause is in react-aria-components, not this PR. Its useTree sets aria-rowcount from the virtualized flag, but its TreeItem never passes that flag down, so the aria-rowindex branch never runs. We cannot fix it from the Cauldron wrapper without patching react-aria. The practical harm is limited, because position still reaches the user through aria-posinset and aria-setsize, and axe does not flag the missing pair. Suggestion: file this upstream with react-aria and note the inconsistency in the documentation.

Usability Gaps

  • Related to the accessibility issue above, browser-find (pressing CMD+F or Control+F) stops working with this PR. Virtualization removes off-screen rows from the DOM, so browser-find cannot find text in the virtualized rows. TreeView has no search or filter prop, so there is no replacement. A consumer who sets height loses find-in-page over the full tree with no warning, making the TreeView less perceivable especially for sighted keyboard users.
  • Similarly, there's also no way to reveal or jump to an off-screen node. Consumers often need to scroll to the selected node or to a node a search matched. The public props expose no scroll-to-key or reveal-node method.

Technical Gaps

  • Expansion is uncontrolled, meaning a consumer cannot implement its own solution to searching the tree. TreeView supports only defaultExpandedKeys (index.tsx:44), so a consumer cannot expand a matched node's ancestors under program control, which they would need to do to implement a search function. This is pre-existing, but this PR makes the impact worse.
  • The whole items array must be built up front. There is no lazy or async loading of children. This caps how large a tree the feature really serves. Rendering is much more memory-intensive than holding list items in memory, but I think virtualization as a feature should make it possible to virtualize a list that is too large to hold, not just to large to render. This could be deferred to later because virtualizing rendering solves the more common, more impactful problem.

Path Forward

I think Virtualization for the TreeView needs a holistic UX design including accessibility consideration because it's a feature, not a bug fix. This design should fill the gaps above as well as any I missed, and explicitly specify the user experience for assistive technology users based on testing with real ATs.

Once we have that, I think your team and the Cauldron maintainers need to decide which parts of the feature will be implemented now and which will be deferred. We should have tickets covering anything that won't be done now, and the docs should mention which parts of this feature are not yet implemented.

Finally, QA will need to test the user experience with assistive technologies to make sure it matches what is intended holistically, rather than just fixing the bug that led to this PR.

I'm not the gatekeeper of what goes in Cauldron. I'm very happy to discuss my concerns if you disagree with them. I'd like to hear from more people from @dequelabs/cauldron-codeowners about what else this feature would need to be included in Cauldron, and from members of @dequelabs/cauldron-design about what it would take to design this feature comprehensively. Mateo is trying to fix a bug, and I want to give him a realistic idea of what it will take to do that with virtualization.

Comment thread packages/react/src/components/TreeView/screenshots.e2e.tsx
Comment thread packages/react/src/components/TreeView/index.tsx Outdated
Comment thread packages/react/src/components/TreeView/TreeView.test.tsx
Comment thread packages/react/src/components/TreeView/screenshots.e2e.tsx
Comment thread packages/react/src/components/TreeView/index.tsx Outdated
…ualization tests

- treat "0"/"0px" the same as numeric 0 (non-virtualized) so an unmeasured
  string height doesn't render a zero-height, row-less scroll region
- reword "janky" in prop docs/JSDoc to plain language
- unit tests: prop height wins over a conflicting style.height; string-zero
  height stays non-virtualized
- e2e: assert only a subset of the 200 rows is in the DOM (windowing regression
  guard) and add a keyboard-focus-retention test across scroll
- docs: note browser-find loss, missing scroll-to API, and the upstream
  react-aria aria-rowindex gap in virtualized mode
@mateoviilla1

Copy link
Copy Markdown
Contributor Author

Thanks @rheisler-deque — this is a really helpful framing, and I agree that virtualization is a feature that deserves a holistic UX + a11y design rather than landing as a bug-fix side effect.

I've addressed the concrete inline suggestions in f3ac120 (string-zero normalization, the "janky" rewording, the windowing DOM-count assertion, the keyboard-focus-retention e2e, and the conflicting-style.height test). Separately, I've documented the current gaps you raised directly in the TreeView docs so no consumer enables height unaware of them:

  • browser find-in-page only matches rendered rows, with no search/filter prop as a replacement yet
  • no programmatic scroll-to / reveal-node API yet
  • the missing aria-rowindex pairing in virtualized mode, noted as a react-aria-components limitation (useTree sets the virtualized flag but TreeItem never forwards it)

On the bigger questions — the AT experience validated against real screen readers, controlled expansion for search, lazy/async children, and which pieces ship now vs. get deferred with tracking tickets — I don't think I should decide those unilaterally. I'd like to get @dequelabs/cauldron-codeowners and @dequelabs/cauldron-design input on scope before I put more into this PR. I'm happy to drive whatever slice we agree lands now and open tickets for the rest.

Worth noting the original driver was a downstream perf bug (dequelabs/axe-reports#3277); if the holistic design is a larger effort, one option is to keep this PR as the rendering-perf fix behind the documented caveats and track the fuller feature separately. Open to whichever path the team prefers.

getByRole('row', { name: 'Item 1' }) matched Item 1/10/11/12 and tripped
Playwright strict mode. Match exact names for the click target and the
asserted focused row.
@frankensteinke

Copy link
Copy Markdown
Contributor

It looks like there is not a cheaper way to address the performance issue (in Cauldron at least), so virtualization seems like a solid approach.

I think that the per-click freeze we are running into is likely caused by react-aria re-rendering every row on selection change. This may be fixable upstream by having each row subscribe only to its own selection state; could be worth logging an issue in react-aria.

One other thing worth checking during AT testing: because the tab stop pins to the last-focused row, mixed mouse-scroll + keyboard re-entry can snap the view back when the current scroll point differs from the last focused item. This is pretty minor though.

To conclude, I think it's probably worth landing this now. Have you tested on the original subdimensions tree to see if it fixes the sluggishness? I think that some of the improvements that Ryan suggested are probably okay to land as follow-ups, assuming our designers agree with them.

@mateoviilla1

Copy link
Copy Markdown
Contributor Author

@frankensteinke Yes, I have tested with a subdimensions tree of 400 children, and cascade selection works properly.

@mateoviilla1

Copy link
Copy Markdown
Contributor Author

Performance analysis: why the freeze scales with tree size

Sharing the cost model behind the lag, with locally-seeded test trees of increasing size. Separating what's exact (complexity + row counts) from what's estimated (the ms constants).

What's actually mounted

The step force-expands the whole tree (defaultExpandedKeys = allKeys), and Cauldron's TreeView (react-aria <Tree>) with no virtualization keeps every row mounted at once. So "displayed items" = every node in the catalog:

Test tree Parents Children Rows mounted ~DOM nodes (~7/row)
flat-20 20 0 20 ~140
flat-50 50 0 50 ~350
10×10 10 100 110 ~770
100×100 100 10,000 10,100 ~70,000

The cost model

Two regimes, both linear in N (rows mounted):

1. Initial mount/paint — create + lay out + style-recalc all N rows:

T_mount ≈ N · c_mount     (c_mount ≈ 50–100 µs/row: DOM creation + react-aria hook setup + style recalc)

2. Per-click re-render — the actual pain point. react-aria's Tree re-renders every mounted row on any selectedKeys change, and React 17 fires 2–3 uncoordinated re-renders per click (pointer / focus / selection events don't batch):

T_click ≈ N · c_row · r   (c_row ≈ 15–75 µs/row reconcile; r ≈ 2–3)

The O(N) tree build and the cascade Set-update are both dominated by this — cascading a parent adds O(children) keys, then still triggers one full O(N) re-render. Nesting itself adds no algorithmic complexity; it just inflates N (a flat 10,100 freezes identically).

Anchoring c_row to the ~150 ms/render figure we'd already measured for a "long list":

Test tree Rows Per-click re-render (× r=2–3) Feel
flat-20 20 ~0.3–1.5 ms instant
10×10 110 ~2–8 ms instant
100×100 10,100 ~150–760 ms × 2–3 ≈ 0.4–2.3 s + ~70k-node style recalc frozen

Where slowness starts (answering the row-count question)

  • It scales linearly, but the constant is brutal because nothing is culled — every row re-renders on every click.
  • Perceptible jank sets in in the low-hundreds of rows (tens of ms/click), and crosses into a clear multi-hundred-ms-to-second freeze in the low-thousands and up. The 100×100 tree (~10k rows) is ~92× the 10×10 tree, so each keystroke costs ~92× more.

What virtualization changes

The height-prop fix changes the complexity class, not the constant: only ~12–15 rows fit a 480 px viewport, so N_mounted becomes viewport-bounded (≈ constant) regardless of catalog size. Per-click cost drops from O(N)O(viewport) — roughly 600–800× fewer rows reconciled per render for the 10k-row tree, turning the ~1 s freeze into <1 ms. The cascade Set-update stays O(descendants), but that's cheap arithmetic, not React reconciliation.

Numbers from locally-seeded perf trees; complexity/row counts are exact, ms figures are estimates anchored to prior local measurement.

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.

4 participants