Skip to content

fix(template): keep virtual scroll state when the viewport is hidden - #1941

Draft
hoebbelsB wants to merge 2 commits into
mainfrom
fix/1778-virtual-scroll-loses-scroll-position
Draft

fix(template): keep virtual scroll state when the viewport is hidden#1941
hoebbelsB wants to merge 2 commits into
mainfrom
fix/1778-virtual-scroll-loses-scroll-position

Conversation

@hoebbelsB

Copy link
Copy Markdown
Member

Closes #1778

Problem

When an ancestor of rx-virtual-scroll-viewport is set to display: none — which is how Ionic's navigation stack and cached router outlets park a page — the list loses its scroll position on the way back, and can come back blank until the user scrolls.

Both of the package's ResizeObserver consumers mistake "hidden" for "measured 0px":

  1. AutoSizeVirtualScrollStrategy.observeViewSize$ gated only on event.target.isConnected, which stays true under display: none. Every rendered view's observer then fired with borderBoxSize[0].blockSize === 0, so the strategy wrote size = 0, cached = true into _virtualItems[index] and subtracted the difference from contentSize. Hiding the list wiped the cached size of every rendered item and shrank the content by their total, so maybeAdjustScrollPosition dragged the list on the way back. This matches @hoebbelsB's guess in the thread.

  2. RxVirtualScrollViewportComponent forwarded the contentRect unfiltered, so containerRect$ emitted {width: 0, height: 0}, containerSize became 0 and the rendered range collapsed to the anchor item. This is the part that survives withResizeObserver="false", which is why @malua still saw a blank list with the flag turned off.

Fix

Treat a ResizeObserverEntry whose border box is collapsed on both axes as "this element is not being rendered", instead of "this element is 0px tall". A genuinely zero-height row still occupies the inline axis of its container, so the two cases are distinguishable without any new configuration.

  • autosize-virtual-scroll-strategy.ts: a module-local isHiddenEntry() helper next to defaultSizeExtract, applied as a filter() right after the existing takeWhile(isConnected) in observeViewSize$. The cached sizes and contentSize are left untouched while hidden; the real measurement arrives with the entry emitted when the element is shown again. The check deliberately runs on the raw entry rather than on the extracted number, so a custom resizeObserverConfig.extractSize cannot defeat it. takeWhile(isConnected) is untouched — detach must still terminate the stream.
  • virtual-scroll-viewport.component.ts: a filter(({ width, height }) => width > 0 || height > 0) ahead of the existing distinctUntilChanged, so containerSize does not collapse while the subtree is hidden.

The fixed-size and dynamic-size strategies consume containerRect$ too and inherit the second guard for free; neither attaches a per-item ResizeObserver, so the first change is autosize-only — consistent with @malua's report that it only happens with autosize.

Tests

New describe('hidden viewport (display: none)') block in autosize.cy.ts, driven by a new AutoSizeHideableTestComponent that wraps the viewport in a display-toggling host:

  • keeps the scroll position across a hide/show cycle
  • does not zero the cached item sizes while hidden (asserts the runway/sentinel height is unchanged while hidden)
  • keeps the rendered range while hidden, run with withResizeObserver both true and false
  • still reacts to a genuine zero-height item — regression guard for the discriminator: a full-width, zero-height row must still be booked as size 0

Each of the two production guards was reverted independently to confirm the tests are real: reverting the strategy filter fails the scroll-position and cached-size tests (the sentinel runway drops from 25299px to 24999px); reverting the viewport guard fails both rendered-range tests (4 items collapse to 2).

npx nx component-test template — 63/63 across all three specs. npx nx test template — 659 passing. npx nx lint template — no new problems.

A short note about hiding the viewport with display: none was added to the virtual-scroll how-to.

Repair

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

The hidden-viewport discriminator rested on a false premise. Treating a fully collapsed ResizeObserver entry as "hidden" also matches items that legitimately measure 0x0 — item templates are position: absolute and shrink to fit, so an empty template is a real measurement, not a hidden one. Replaced with a hasLayoutBox() helper using element.getClientRects().length > 0, which is empty only when the element genuinely has no CSS layout box.

Verification

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

Open questions

  • prettier --check flags both touched source files, but the same failure reproduces on their unmodified HEAD versions (it wants the implements ... clause on one line). Pre-existing formatting drift, presumably a prettier version difference — I left it alone rather than reformatting unrelated lines into the diff.
  • The test for the viewport guard asserts the rendered range WHILE the subtree is hidden, because once it is shown again containerRect$ re-emits and the range recovers within the test window. The real-world 'blank list until you scroll' symptom likely needs the recovery emission to be swallowed by onlyTriggerWhenStable() in calcRenderedRange (it filters when scrollTop !== anchorScrollTop), which I could not reproduce deterministically in Cypress. The guard prevents containerSize from ever reaching 0, so the hazard is removed either way, but the exact production race is unverified.
  • Step 3 touches the same component as open PR fix(template): keep scroll offset when using keepScrolledIndexOnPrepend #1896 (its ngAfterViewInit containerRect$ seeding), but a different method — the rebase is trivial, as the plan predicted.
  • The shared git stash stack across worktrees bit this workflow again (a previous agent had already re-stored the same sibling stash with a 'popped by mistake' note). Worth telling future agents to never use git stash here.

The autosize strategy and the viewport both listen to a ResizeObserver.
When an ancestor is set to `display: none` - as Ionic's page stack and
cached router outlets do - the observer reports a fully collapsed box for
every rendered view as well as for the viewport itself. Those zeros were
taken for real measurements: the strategy wiped the cached size of every
rendered item and shrank `contentSize` accordingly, while the viewport
forwarded a 0x0 `containerRect` that collapsed the rendered range.
Showing the list again therefore dragged the scroll position and could
leave the list blank until the user scrolled.

Ignore ResizeObserver entries whose border box is collapsed on both axes.
A genuinely zero-height view still occupies the inline axis of its
container, so this discriminates "not rendered" from "0px tall" without
requiring new configuration. The check runs on the raw entry, so a custom
`resizeObserverConfig.extractSize` cannot defeat it.

Closes #1778
…ro size

The discriminator treated a fully collapsed ResizeObserver entry as hidden,
which also matches items that legitimately measure 0x0 - item templates are
`position: absolute` and shrink to fit, so an empty template is a real
measurement. Use `getClientRects().length === 0`, which is empty only when the
element has no CSS layout box at all.
@nx-cloud

nx-cloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 97f33f4

Command Status Duration Result
nx-cloud record -- npx nx format:check ❌ Failed 1s View ↗
nx build demos --configuration=production ✅ Succeeded 16s View ↗
nx build docs ✅ Succeeded 1m 17s 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:11 UTC

@github-actions github-actions Bot added </> Template @rx-angular/template related 📚 Docs Web Documentation hosted on github pages labels Aug 4, 2026
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.

Virtual Scroll loses scroll position on display: none

1 participant