Skip to content

feat(template): add visibleRange output to rx-virtual-scroll-viewport - #1940

Draft
hoebbelsB wants to merge 2 commits into
mainfrom
fix/1865-appendonly-true-behavior-in-autosizevirtualscrollstrategy
Draft

feat(template): add visibleRange output to rx-virtual-scroll-viewport#1940
hoebbelsB wants to merge 2 commits into
mainfrom
fix/1865-appendonly-true-behavior-in-autosizevirtualscrollstrategy

Conversation

@hoebbelsB

Copy link
Copy Markdown
Member

What

Adds a second range output to rx-virtual-scroll-viewport:

<rx-virtual-scroll-viewport autosize appendOnly (visibleRange)="loadDataFor($event)" (viewRange)="rendered = $event">

visibleRange emits the range of items intersecting the viewport: start is the index of the first, end the exclusive index of the last intersecting item. Partially visible items count. It is never widened by runwayItems, runwayItemsOpposite or appendOnly.

viewRange is unchanged.

Why

Closes #1865.

viewRange documents itself as "the range of items being actually rendered to the DOM", and the strategies deliberately widen it:

if (this.appendOnly) {
  range.start = Math.min(this._renderedRange.start, range.start);
  range.end = Math.max(this._renderedRange.end, range.end);
}

With appendOnly that pins viewRange.start to 0 forever, which is correct for the rendered range but leaves consumers with no way to tell where the user actually is — the blocker for lazy loading described in the issue. Per the discussion in #1865, the fix is a second output rather than a change to viewRange.

How

  • RxVirtualScrollStrategy (@publicApi) gets a concrete, overridable get visibleRange$() that returns renderedRange$. It is deliberately not abstract, so existing third-party strategies keep compiling and keep emitting; they simply fall back to the old behaviour until they opt in.
  • Each built-in strategy computes the visible range in the same map() as the rendered range, before the appendOnly widening:
    • autosize / dynamic-size: { start: anchorItem.index, end: min(length, lastScreenItem.index + 1) }
    • fixed-size: derived from scrollTop and the visible container size, reusing the existing scrolledIndex expression for start
  • start is intentionally the same value as the existing scrolledIndexChange, which is already defined as "the topmost item actually being visible".

Tests

npx nx component-test template — a new describe('visibleRange') block in each of the three specs (fixed-size, dynamic-size, autosize):

  1. emits the visible range, excluding runway items
  2. advances visibleRange.start while scrolling with appendOnly=true — the appendOnly=true Behavior in AutoSizeVirtualScrollStrategy Causes viewRange.start to Always Be 0 #1865 regression: asserts viewRange.start is still 0 (unchanged, documented behaviour) while visibleRange.start follows the viewport
  3. visibleRange.start tracks scrolledIndexChange
  4. reacts to containerHeight changes

Plus a unit assertion that a strategy which does not override visibleRange$ still emits, proving the new member is non-breaking.

Verified the tests are real: with only the three strategy implementations reverted (model/viewport/tests intact), 4 tests fail per spec with e.g. expected visibleRange to have been called with arguments {start: 100, end: 106}, and every pre-existing test still passes.

71/71 component tests, 659 jest tests and nx lint template (0 errors) pass with the change.

Docs

  • visibleRange row added to the outputs table in rx-virtual-scroll-viewport.md
  • pointer to it from the appendOnly row in rx-virtual-scroll-strategies.md

Repair

A review of the first commit found blocking defects. Addressed in the follow-up commit:

Off-by-one in visibleRange.end. It unconditionally added 1 to lastScreenItem.index, so an item whose top edge landed exactly on the bottom edge of the viewport was reported as visible although none of it is. Fixed in both the autosize and dynamic-size strategies via lastScreenItem.index + (lastScreenItem.offset > 0 ? 1 : 0).

The original tests could not have caught this: the expectation helper reimplemented the production formula, so it agreed with the bug. The tests now derive the expected range independently.

Verification

Cypress component tests: 78 specs, all passing (nx component-test template). Lint: 0 errors.

Open questions

  • nx build-lib template could not be verified here: the target shells out to yarn tsc -p libs/cdk/tsconfig.schematics.json and yarn fails inside the git worktree with "Couldn't find the node_modules state file". This is an environment limitation of the worktree, not a regression — the public API was instead typechecked with npx tsc -p libs/template/tsconfig.lib.json --noEmit, which reports only one pre-existing error in libs/template/virtual-view/src/lib/tests/test-utils/testbed.ts that is also present on the untouched baseline.
  • Should visibleRange also be surfaced in the how-to page apps/docs/docs/packages/template/how-to/virtual-scroll-recipes.md? Its infinite-scroll recipe currently drives loading off viewRange/scrolledIndexChange and would arguably be better written against visibleRange, but rewriting a recipe felt out of scope for a bug fix — left for the maintainer to decide.
  • autosize's renderedRange$ is a plain Subject while the other two use ReplaySubject(1). The new _visibleRange$ is a ReplaySubject(1) in all three for consistency, so a late subscriber to autosize's visibleRange gets the current range whereas a late subscriber to viewRange does not. That asymmetry seemed like the more useful behaviour, but it is a deliberate difference worth a maintainer's eye.

`viewRange` reports the range rendered to the DOM, which every scroll
strategy deliberately widens by the runway items and, when `appendOnly`
is enabled, by everything that has ever been rendered. That keeps its
`start` at 0 for the entire lifetime of an appendOnly list, so it can't
be used to figure out where the user actually is.

Add a second output, `visibleRange`, emitting the range of items
intersecting the viewport - `start` is the index of the first, `end` the
exclusive index of the last intersecting item. All three built-in
strategies compute it right next to their rendered range, before the
appendOnly widening kicks in. On `RxVirtualScrollStrategy` it defaults to
`renderedRange$`, so third party strategies keep working unchanged.

Closes #1865
…m from visibleRange

`visibleRange.end` unconditionally added 1 to `lastScreenItem.index`, so an
item whose top edge lands exactly on the bottom edge of the viewport was
reported as visible although none of it is. The tests could not detect this
because the expectation helper reimplemented the production formula; they now
derive the expected range independently.
@github-actions github-actions Bot added </> Template @rx-angular/template related 📚 Docs Web Documentation hosted on github pages labels Aug 4, 2026
@nx-cloud

nx-cloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 97cd718

Command Status Duration Result
nx-cloud record -- npx nx format:check ❌ Failed 2s View ↗
nx build docs ✅ Succeeded 1m 15s View ↗
nx build demos --configuration=production ✅ Succeeded 16s View ↗

💡 Dealing with memory or CPU issues? See memory and CPU details with the resource usage add-on ↗.


☁️ Nx Cloud last updated this comment at 2026-08-04 06:46:57 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📚 Docs Web Documentation hosted on github pages </> Template @rx-angular/template related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

appendOnly=true Behavior in AutoSizeVirtualScrollStrategy Causes viewRange.start to Always Be 0

1 participant