Skip to content

Commit edce6af

Browse files
Fix InfiniteScroll scroll preservation (#2689)
* Fix `InfiniteScroll` scroll preservation * Added test --------- Co-authored-by: Pascal Baljet <[email protected]>
1 parent 420d804 commit edce6af

File tree

8 files changed

+99
-6
lines changed

8 files changed

+99
-6
lines changed

packages/core/src/domUtils.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
const elementInViewport = (el: HTMLElement) => {
2+
if (el.offsetParent === null) {
3+
// Element is not participating in layout (e.g., display: none)
4+
return false
5+
}
6+
27
const rect = el.getBoundingClientRect()
38

49
// We check both vertically and horizontally for containers that scroll in either direction
@@ -73,9 +78,13 @@ export const getScrollableParent = (element: HTMLElement | null): HTMLElement |
7378
}
7479

7580
export const getElementsInViewportFromCollection = (
76-
referenceElement: HTMLElement,
7781
elements: HTMLElement[],
82+
referenceElement?: HTMLElement,
7883
): HTMLElement[] => {
84+
if (!referenceElement) {
85+
return elements.filter((element) => elementInViewport(element))
86+
}
87+
7988
const referenceIndex = elements.indexOf(referenceElement)
8089
const upwardElements: HTMLElement[] = []
8190
const downwardElements: HTMLElement[] = []

packages/core/src/infiniteScroll/queryString.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export const useInfiniteScrollQueryString = (options: {
8585
const pageMap = new Map<string, number>()
8686
const elements = [...itemsElement.children] as HTMLElement[]
8787

88-
getElementsInViewportFromCollection(itemElement, elements).forEach((element) => {
88+
getElementsInViewportFromCollection(elements, itemElement).forEach((element) => {
8989
const page = getPageFromElement(element) ?? '1'
9090

9191
if (pageMap.has(page)) {

packages/core/src/infiniteScroll/scrollPreservation.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,7 @@ export const useInfiniteScrollPreservation = (options: {
2727

2828
// Find the first visible element to use as a reference point
2929
// This element will help us calculate how much the content shifted after the update
30-
const visibleElements = getElementsInViewportFromCollection(
31-
itemsElement.firstElementChild as HTMLElement,
32-
[...itemsElement.children] as HTMLElement[],
33-
)
30+
const visibleElements = getElementsInViewportFromCollection([...itemsElement.children] as HTMLElement[])
3431

3532
if (visibleElements.length > 0) {
3633
referenceElement = visibleElements[0]
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { InfiniteScroll } from '@inertiajs/react'
2+
import UserCard, { User } from './UserCard'
3+
4+
export default ({ users }: { users: { data: User[] } }) => {
5+
return (
6+
<div>
7+
<h1>Infinite Scroll with Invisible First Child</h1>
8+
9+
<InfiniteScroll
10+
data="users"
11+
style={{ display: 'grid', gap: '20px' }}
12+
loading={() => <div style={{ textAlign: 'center', padding: '20px' }}>Loading...</div>}
13+
>
14+
<div style={{ display: 'none' }}>Hidden first element</div>
15+
16+
{users.data.map((user) => (
17+
<UserCard key={user.id} user={user} />
18+
))}
19+
</InfiniteScroll>
20+
</div>
21+
)
22+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<script lang="ts">
2+
import { InfiniteScroll } from '@inertiajs/svelte'
3+
import UserCard, { type User } from './UserCard.svelte'
4+
5+
export let users: { data: User[] }
6+
</script>
7+
8+
<div>
9+
<h1>Infinite Scroll with Invisible First Child</h1>
10+
11+
<InfiniteScroll data="users" style="display: grid; gap: 20px">
12+
<div slot="loading" style="text-align: center; padding: 20px">Loading...</div>
13+
14+
<div style="display: none">Hidden first element</div>
15+
16+
{#each users.data as user (user.id)}
17+
<UserCard {user} />
18+
{/each}
19+
</InfiniteScroll>
20+
</div>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<script setup lang="ts">
2+
import { InfiniteScroll } from '@inertiajs/vue3'
3+
import { User, default as UserCard } from './UserCard.vue'
4+
5+
defineProps<{
6+
users: { data: User[] }
7+
}>()
8+
</script>
9+
10+
<template>
11+
<div>
12+
<h1>Infinite Scroll with Invisible First Child</h1>
13+
14+
<InfiniteScroll data="users" style="display: grid; gap: 20px">
15+
<div style="display: none">Hidden first element</div>
16+
17+
<UserCard v-for="user in users.data" :key="user.id" :user="user" />
18+
19+
<template #loading>
20+
<div style="text-align: center; padding: 20px">Loading...</div>
21+
</template>
22+
</InfiniteScroll>
23+
</div>
24+
</template>

tests/app/server.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,9 @@ app.get('/infinite-scroll/programmatic-ref', (req, res) =>
10461046
app.get('/infinite-scroll/short-content', (req, res) =>
10471047
renderInfiniteScroll(req, res, 'InfiniteScroll/ShortContent', 100, false, 5),
10481048
)
1049+
app.get('/infinite-scroll/invisible-first-child', (req, res) =>
1050+
renderInfiniteScroll(req, res, 'InfiniteScroll/InvisibleFirstChild'),
1051+
)
10491052
app.get('/infinite-scroll/reload-unrelated', (req, res) => {
10501053
const page = req.query.page ? parseInt(req.query.page) : 1
10511054
const partialReload = !!req.headers['x-inertia-partial-data']

tests/infinite-scroll.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1406,6 +1406,24 @@ test.describe('Scroll position preservation', () => {
14061406

14071407
expect(afterScreenshot).toEqual(beforeScreenshot)
14081408
})
1409+
1410+
test('it maintains scroll position when first child element is invisible', async ({ page }) => {
1411+
await page.goto('/infinite-scroll/invisible-first-child?page=2')
1412+
1413+
// Verify the invisible element exists but is not visible
1414+
const hiddenElement = await page.locator('text="Hidden first element"')
1415+
await expect(hiddenElement).toBeAttached()
1416+
await expect(hiddenElement).toBeHidden()
1417+
1418+
// Page 1 loads immediately since the start trigger is visible
1419+
await expect(page.getByText('User 16')).toBeVisible()
1420+
await expect(page.getByText('Loading...')).toBeVisible()
1421+
await expect(page.getByText('User 1', { exact: true })).toBeVisible()
1422+
1423+
// Make sure the browser didn't scroll to the top...
1424+
const scrollY = await page.evaluate(() => window.scrollY)
1425+
expect(scrollY).toBeGreaterThan(100 * 15)
1426+
})
14091427
})
14101428

14111429
test.describe('Scrollable container support', () => {

0 commit comments

Comments
 (0)