Skip to content

feat: Added virtual scroll component and sample implementation - #17281

Merged
rkaraivanov merged 39 commits into
masterfrom
rkaraivanov/virtual-scroll-component
Sep 18, 2026
Merged

rkaraivanov merged 39 commits into
masterfrom
rkaraivanov/virtual-scroll-component

Conversation

@rkaraivanov

@rkaraivanov rkaraivanov commented May 18, 2026

Copy link
Copy Markdown
Member

Closes #17607
Closes #17608
Closes #17610
Closes #17611
Closes #17615
Closes #17616

Description

Expose a new virtual scroll component for horizontal/vertical scrolling.

Motivation / Context

  1. Self-contained viewport — The component is the scrollable element. igxForOf requires the developer to manually wire a separate scroll container, a VirtualHelperComponent/HVirtualHelperComponent DOM helper element, and pass igxForContainerSize explicitly. That's 3–4 pieces of setup vs. one tag.
  2. Signals-first reactive model.
  3. View pooling — Active views are kept in a pool and detached/reattached instead of destroyed/recreated on every scroll. igxForOf uses ViewContainerRef.move() but always keeps a fixed chunk alive, paying create/destroy cost when chunk size changes.
  4. Clean ChangeDetectionStrategy.OnPush throughout the implementation.
  5. Better DX for remote/infinite scrolling.
  6. No auxiliary helper components in the DOM.
  7. Typed template context.

Type of Change (check all that apply):

  • Bug fix
  • New functionality
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactoring (no functional changes)
  • Documentation
  • Demos
  • CI/CD
  • Tests
  • Changelog
  • Skills/Agents

Component(s) / Area(s) Affected:

How Has This Been Tested?

  • Unit tests
  • Manual testing
  • Automated e2e tests

Test Configuration:

  • Angular version:
  • Browser(s):
  • OS:

Screenshots / Recordings

Checklist:

  • All relevant tags have been applied to this PR
  • This PR includes unit tests covering all the new code (test guidelines)
  • This PR includes API docs for newly added methods/properties (api docs guidelines)
  • This PR includes feature/README.MD updates for the feature docs
  • This PR includes general feature table updates in the root README.MD
  • This PR includes CHANGELOG.MD updates for newly added functionality
  • This PR contains breaking changes
  • This PR includes ng update migrations for the breaking changes (migrations guidelines)
  • This PR includes behavioral changes and the feature specification has been updated with them
  • Accessibility (ARIA, keyboard navigation, focus management) has been verified

rkaraivanov and others added 16 commits May 22, 2026 10:58
Fixed several issues with virtual coordinates mapping and the scroll engine in general.
The Binary Indexed Tree (BIT) is used to efficiently calculate the cumulative heights of items in the virtual scroll,
which allows for faster updates and smoother scrolling experience.
Copilot AI review requested due to automatic review settings July 28, 2026 14:23

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

This PR introduces a new IgxVirtualScrollComponent entry point to Ignite UI for Angular, providing a self-contained virtualized viewport for vertical and horizontal scrolling (including variable-size items and remote/infinite loading), and adds a demo route + sample showcasing usage.

Changes:

  • Added a new igniteui-angular/virtual-scroll entry point with a virtual scroll component, item-template directive, supporting types, and a scroll-math engine.
  • Added unit tests covering the engine behavior, template context, orientations (including RTL), outputs, and basic rendering.
  • Added a demo/sample implementation and wired it into the demo app routing + navigation list.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/app/virtual-scroll/virtual-scroll.sample.ts Demo component showcasing vertical/horizontal + remote data scenarios.
src/app/virtual-scroll/virtual-scroll.sample.html Sample template demonstrating igxVirtualItem usage for multiple scenarios.
src/app/virtual-scroll/virtual-scroll.sample.scss Styling for the new demo page.
src/app/app.routes.ts Adds a /virtual-scroll route for the demo.
src/app/app.component.ts Adds “Virtual Scroll” to the demo navigation list.
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts Core virtual scroll component implementation (signals-first + view pooling).
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.html Component template for track/content positioning.
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.scss Component styles for vertical/horizontal layouts.
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll-item.directive.ts Directive to capture ng-template[igxVirtualItem] as the item template.
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts Typed template context + output payload types.
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/scroll-engine.ts Scroll math engine (BIT-based sizing, visible range computation, coordinate compression).
projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts Unit tests for engine + component behaviors (including RTL).
projects/igniteui-angular/virtual-scroll/src/public_api.ts Public exports for the new entry point.
projects/igniteui-angular/virtual-scroll/index.ts Entry-point barrel export.
projects/igniteui-angular/virtual-scroll/ng-package.json ng-packagr entry point config placeholder (consistent with other entry points).
projects/igniteui-angular/virtual-scroll/README.md Feature documentation and usage examples for the new component.
projects/igniteui-angular/src/public_api.ts Exposes igniteui-angular/virtual-scroll from the root package exports.
Comments suppressed due to low confidence (1)

src/app/virtual-scroll/virtual-scroll.sample.html:107

  • Section numbering in the sample comments labels this as section 6 even though it is the 5th sample block. Renumber to keep the sample easier to follow.
    <!-- 6. Remote / infinite scrolling                                      -->
    <!-- ------------------------------------------------------------------ -->

Comment thread src/app/virtual-scroll/virtual-scroll.sample.ts
Comment thread src/app/virtual-scroll/virtual-scroll.sample.html Outdated
Comment thread projects/igniteui-angular/virtual-scroll/src/public_api.ts Outdated
@rkaraivanov

Copy link
Copy Markdown
Member Author

@simeonoff
Can someone from your team take a look at styling of the component? I'm asking mainly to check whether it correctly works with the new scoped styles and theming.

@rkaraivanov rkaraivanov added the squash-merge Merge PR with "Squash and Merge" option label Sep 16, 2026
Comment thread projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.ts Outdated
Comment thread CHANGELOG.md Outdated
Comment thread projects/igniteui-angular/combo/src/combo/combo.common.ts
ChronosSF and others added 2 commits September 17, 2026 18:10
… change

Two regressions in IgxVirtualScrollComponent, captured as tests that fail
on the current implementation:

- scrollToIndex with block/inline 'nearest' start-aligns an item that is
  out of view. Native scrollIntoView semantics end-align an item below or
  past the viewport, which is what drop-down, combo and the Excel-style
  filter rely on for ArrowDown navigation.

- _contentTransform reads the host direction through _isLTR() inside a
  computed(), so a runtime dir change on a horizontal list does not flip
  the translateX sign while the rendered range stays the same.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ChronosSF

Copy link
Copy Markdown
Member

Some findings during agentic testing:

  1. nearest alignment start-aligns out-of-view items. scrollToIndex(i, { block: 'nearest' }) returns the current offset when the item is already visible, but otherwise falls through to start. Native scrollIntoView semantics, which the docs claim to match, end-align an item that is below or past the viewport. Drop-down, combo and the Excel-style filter all navigate with nearest, so ArrowDown past the bottom edge now jumps the row to the top of the list instead of revealing it at the bottom the way igxFor did. Tests: should end-align an item below the viewport for block: nearest and the horizontal inline: nearest mirror both fail with Expected 500 to be 250. The start case for an item above the viewport passes and is included for completeness.

  2. Direction is read non-reactively inside _contentTransform. The computed calls _isLTR(), which reads computed style, so it only re-evaluates when the rendered range or the engine version changes. Flipping dir at runtime on a horizontal list while the window stays put leaves the wrapper at translateX(+x) instead of translateX(-x). Tests: should flip the content transform when the direction changes to rtl at runtime and its ltr mirror fail with Expected 'translateX(400px)' to be 'translateX(-400px)' and the reverse. The tests deliberately hold the rendered range constant across the flip, since that is the only state in which the bug is observable. A fix needs the direction itself to be a tracked signal, not a DOM read inside the computed.

Both have tests added that will fail in the latest commit. We need to fix these before merge.

Three more regressions captured as tests that fail on the current code:

- Under coordinate compression the visible range at the maximum DOM
  scroll offset stops short of the last items, so the tail of a very
  long list can never be reached.

- Replacing `data` with rows of unchanged size discards their
  measurements but never re-measures the recycled wrappers, leaving the
  engine on the estimate.

- navigateFirst on a remote page that starts past index 0 lands on the
  group header once the first page arrives. The spec now asserts the
  loaded page starts past 0, so it cannot pass through the local path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@ChronosSF

Copy link
Copy Markdown
Member

Added 3 more tests that fail due to actual issues:

  1. Compressed lists cannot reach their last rows
    When the total size exceeds the browser's scroll limit, the engine maps DOM scroll to virtual space by scaling the total: virtual start = DOM scroll × totalSize ÷ domSize. At the largest scrollable offset, domSize − viewport, that lands at totalSize − viewport × ratio, and the window only spans one viewport from there. The last viewport × (ratio − 1) virtual pixels are never in the visible range, so with a million 50 px rows Firefox silently drops the final 8 to 33 rows depending on viewport height and Chrome the final 3 to 7. The content-transform clamp does not help, since it only positions rows that were selected for rendering.
    Suggested fix: compress the scrollable range, not the total. Virtual start = DOM scroll × (totalSize − viewport) ÷ (domSize − viewport), so the maximum DOM offset maps exactly to totalSize − viewport. getVisibleRange, getScrollOffsetForIndex, getAlignedScrollOffset and isIndexInView must all share that mapping, which means the offset conversions need the viewport size, not only the range query. With that mapping all three assertions in the test hold as written.

  2. Replacing data with same-size rows leaves the engine on the estimate
    Assigning a new array changes every item's identity, so the retain count is zero and every size is rebuilt from estimatedItemSize. The recycled wrappers keep their data-index, so _scheduleItemMeasurement skips re-observing them, and ResizeObserver stays silent because the element's size did not change. Rendered rows therefore keep the estimate indefinitely, and the track size, scrollToIndex targets and stateChange.totalSize are off by the difference.
    Fix direction: whenever a resize discards measurements, re-measure the wrappers currently in the DOM, either by re-registering them with the observer or by reading their border box in the after-render pass.

  3. Remote first page lands focus on a group header
    When the loaded page starts past index 0, navigateFirst calls navigateItem(0). The scroll resolves before the page for index 0 exists, so skipHeader finds no rendered item and returns. When the page then arrives, index 0 renders as the group header, aria-activedescendant names it, and focusedItem reports a header. The igxFor path always landed on the first selectable item. The test asserts the loaded page starts past 0 first, so it cannot pass through the local path.
    Fix direction: after a page arrives, re-run the header skip for the pending focused index, or defer the skip until the target index is rendered rather than until the scroll settles.

Compressed lists could not reach their last rows: the engine scaled
sizes, so the largest DOM offset landed one viewport of compressed
pixels short. It now maps the scroll ranges, which is why offset
conversions take the viewport size.

`nearest` start-aligned every out-of-view item. It now brings the item
to its nearer edge, as native scrollIntoView does.

The content transform read the direction outside the signal graph, so
a runtime `dir` change left the wrapper mirrored the wrong way until
the window moved.

Rows replaced at the same size kept the estimate after a resize dropped
their measurements: an unchanged wrapper never reports on its own, so
those wrappers are observed again.

The drop-down skipped headers when the scroll settled, before a remote
first page had rendered the focused row, and marked itself for check
on every window change even when the active descendant had not moved.

The Excel style search read a private ChangeDetectorRef flag through
`any`; it uses the typed ViewRef like its siblings.
@ChronosSF ChronosSF added ✅ status: verified Applies to PRs that have passed manual verification and removed ❌ status: awaiting-test PRs awaiting manual verification labels Sep 18, 2026
ChronosSF
ChronosSF previously approved these changes Sep 18, 2026
@ChronosSF
ChronosSF enabled auto-merge (squash) September 18, 2026 11:47
The navigation waits for the grid's chunkLoad with first(), yet a
chunk load also fires when a chunk size changes, as the freshly created
child grid's container does while it settles. When that load came
before the navigation's own scroll, the callback expanded the target
row while the child row was still outside the rendered chunk. The
nested grid never rendered, so the lookup came back empty and the
navigation ended early. This timing made the nested child grid spec
flaky in CI.

The navigation now scrolls the child row into the chunk when its grid
is missing, and continues on the first chunk load that has it.
@rkaraivanov
rkaraivanov merged commit fd7d7d9 into master Sep 18, 2026
6 checks passed
@rkaraivanov
rkaraivanov deleted the rkaraivanov/virtual-scroll-component branch September 18, 2026 14:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

combo grid: excel-style-filtering simple-combo squash-merge Merge PR with "Squash and Merge" option virtual-scroll ✅ status: verified Applies to PRs that have passed manual verification

Projects

None yet

5 participants