fix(template): keep virtual scroll state when the viewport is hidden - #1941
Draft
hoebbelsB wants to merge 2 commits into
Draft
fix(template): keep virtual scroll state when the viewport is hidden#1941hoebbelsB wants to merge 2 commits into
hoebbelsB wants to merge 2 commits into
Conversation
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.
|
View your CI Pipeline Execution ↗ for commit 97f33f4
💡 Dealing with memory or CPU issues? See memory and CPU details with the resource usage add-on ↗. ☁️ Nx Cloud last updated this comment at |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1778
Problem
When an ancestor of
rx-virtual-scroll-viewportis set todisplay: 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
ResizeObserverconsumers mistake "hidden" for "measured 0px":AutoSizeVirtualScrollStrategy.observeViewSize$gated only onevent.target.isConnected, which staystrueunderdisplay: none. Every rendered view's observer then fired withborderBoxSize[0].blockSize === 0, so the strategy wrotesize = 0,cached = trueinto_virtualItems[index]and subtracted the difference fromcontentSize. Hiding the list wiped the cached size of every rendered item and shrank the content by their total, somaybeAdjustScrollPositiondragged the list on the way back. This matches @hoebbelsB's guess in the thread.RxVirtualScrollViewportComponentforwarded thecontentRectunfiltered, socontainerRect$emitted{width: 0, height: 0},containerSizebecame0and the rendered range collapsed to the anchor item. This is the part that surviveswithResizeObserver="false", which is why @malua still saw a blank list with the flag turned off.Fix
Treat a
ResizeObserverEntrywhose 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-localisHiddenEntry()helper next todefaultSizeExtract, applied as afilter()right after the existingtakeWhile(isConnected)inobserveViewSize$. The cached sizes andcontentSizeare 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 customresizeObserverConfig.extractSizecannot defeat it.takeWhile(isConnected)is untouched — detach must still terminate the stream.virtual-scroll-viewport.component.ts: afilter(({ width, height }) => width > 0 || height > 0)ahead of the existingdistinctUntilChanged, socontainerSizedoes 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-itemResizeObserver, so the first change is autosize-only — consistent with @malua's report that it only happens withautosize.Tests
New
describe('hidden viewport (display: none)')block inautosize.cy.ts, driven by a newAutoSizeHideableTestComponentthat wraps the viewport in adisplay-toggling host:keeps the scroll position across a hide/show cycledoes not zero the cached item sizes while hidden(asserts the runway/sentinel height is unchanged while hidden)keeps the rendered range while hidden, run withwithResizeObserverbothtrueandfalsestill reacts to a genuine zero-height item— regression guard for the discriminator: a full-width, zero-height row must still be booked as size 0Each 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: nonewas 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
ResizeObserverentry as "hidden" also matches items that legitimately measure0x0— item templates areposition: absoluteand shrink to fit, so an empty template is a real measurement, not a hidden one. Replaced with ahasLayoutBox()helper usingelement.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 --checkflags both touched source files, but the same failure reproduces on their unmodified HEAD versions (it wants theimplements ...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.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 byonlyTriggerWhenStable()incalcRenderedRange(it filters whenscrollTop !== anchorScrollTop), which I could not reproduce deterministically in Cypress. The guard preventscontainerSizefrom ever reaching 0, so the hazard is removed either way, but the exact production race is unverified.ngAfterViewInitcontainerRect$ seeding), but a different method — the rebase is trivial, as the plan predicted.git stashstack 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 usegit stashhere.