Skip to content

Commit 88277c8

Browse files
authored
Fix conflicting Client Side Visits by queuing the URL synchronization (#2613)
* Maintain DOM order when getting visible elements * Update queryString.ts * Update queryString.ts * Update infinite-scroll.spec.ts * Update queryString.ts * Update ProgrammaticRef.tsx * Added test * Fix code style * Update DualSibling.svelte
1 parent 0270f49 commit 88277c8

File tree

8 files changed

+306
-35
lines changed

8 files changed

+306
-35
lines changed

packages/core/src/domUtils.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,15 @@ export const getElementsInViewportFromCollection = (
2929
elements: HTMLElement[],
3030
): HTMLElement[] => {
3131
const referenceIndex = elements.indexOf(referenceElement)
32-
const visibleElements: HTMLElement[] = []
32+
const upwardElements: HTMLElement[] = []
33+
const downwardElements: HTMLElement[] = []
3334

3435
// Traverse upwards until an element is not visible
3536
for (let i = referenceIndex; i >= 0; i--) {
3637
const element = elements[i]
3738

3839
if (elementInViewport(element)) {
39-
visibleElements.push(element)
40+
upwardElements.push(element)
4041
} else {
4142
break
4243
}
@@ -47,11 +48,12 @@ export const getElementsInViewportFromCollection = (
4748
const element = elements[i]
4849

4950
if (elementInViewport(element)) {
50-
visibleElements.push(element)
51+
downwardElements.push(element)
5152
} else {
5253
break
5354
}
5455
}
5556

56-
return visibleElements
57+
// Reverse upward elements to maintain DOM order, then append downward elements
58+
return [...upwardElements.reverse(), ...downwardElements]
5759
}

packages/core/src/infiniteScroll/queryString.ts

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
import { router } from '..'
12
import debounce from '../debounce'
23
import { getElementsInViewportFromCollection } from '../domUtils'
3-
import { router } from '../index'
4+
import Queue from './../queue'
45
import { getPageFromElement } from './elements'
56

7+
// Shared queue among all instances to ensure URL updates are processed sequentially
8+
const queue = new Queue<Promise<void>>()
9+
10+
let initialUrl: URL | null
11+
let payloadUrl: URL | null
12+
613
/**
714
* As users scroll through infinite content, this system updates the URL to reflect
815
* which page they're currently viewing. It uses a "most visible page" calculation
@@ -15,6 +22,47 @@ export const useInfiniteScrollQueryString = (options: {
1522
}) => {
1623
let enabled = true
1724

25+
const queuePageUpdate = (page: string) => {
26+
queue
27+
.add(() => {
28+
return new Promise((resolve) => {
29+
if (!enabled) {
30+
initialUrl = payloadUrl = null
31+
return resolve()
32+
}
33+
34+
if (!initialUrl || !payloadUrl) {
35+
initialUrl = new URL(window.location.href)
36+
payloadUrl = new URL(window.location.href)
37+
}
38+
39+
const pageName = options.getPageName()
40+
const searchParams = payloadUrl.searchParams
41+
42+
// Clean URLs: don't show ?page=1 in the URL, just remove the parameter entirely
43+
if (page === '1') {
44+
searchParams.delete(pageName)
45+
} else {
46+
searchParams.set(pageName, page)
47+
}
48+
49+
setTimeout(() => resolve())
50+
})
51+
})
52+
.finally(() => {
53+
if (enabled && initialUrl && payloadUrl && initialUrl.href !== payloadUrl.href) {
54+
// Update URL without triggering a page reload or affecting scroll position
55+
router.replace({
56+
url: payloadUrl.toString(),
57+
preserveScroll: true,
58+
preserveState: true,
59+
})
60+
}
61+
62+
initialUrl = payloadUrl = null
63+
})
64+
}
65+
1866
// Debounced to avoid excessive URL updates during fast scrolling
1967
const onItemIntersected = debounce((itemElement: HTMLElement) => {
2068
const itemsElement = options.getItemsElement()
@@ -41,25 +89,9 @@ export const useInfiniteScrollQueryString = (options: {
4189
const sortedPages = Array.from(pageMap.entries()).sort((a, b) => b[1] - a[1])
4290
const mostVisiblePage = sortedPages[0]?.[0]
4391

44-
if (mostVisiblePage === undefined) {
45-
return
92+
if (mostVisiblePage !== undefined) {
93+
queuePageUpdate(mostVisiblePage)
4694
}
47-
48-
const url = new URL(window.location.href)
49-
50-
// Clean URLs: don't show ?page=1 in the URL, just remove the parameter entirely
51-
if (mostVisiblePage === '1') {
52-
url.searchParams.delete(options.getPageName())
53-
} else {
54-
url.searchParams.set(options.getPageName(), mostVisiblePage.toString())
55-
}
56-
57-
// Update URL without triggering a page reload or affecting scroll position
58-
router.replace({
59-
url: url.toString(),
60-
preserveScroll: true,
61-
preserveState: true,
62-
})
6395
}, 250)
6496

6597
return {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { InfiniteScroll } from '@inertiajs/react'
2+
import UserCard, { User } from './UserCard'
3+
4+
interface Props {
5+
users1: { data: User[] }
6+
users2: { data: User[] }
7+
}
8+
9+
export default ({ users1, users2 }: Props) => {
10+
return (
11+
<div style={{ padding: '20px' }}>
12+
<h1>Dual Sibling InfiniteScroll</h1>
13+
<p style={{ marginBottom: '20px' }}>Two InfiniteScroll components side by side, sharing the window scroll</p>
14+
15+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '40px' }}>
16+
<div>
17+
<h2>Users 1</h2>
18+
<InfiniteScroll
19+
data="users1"
20+
style={{ display: 'grid', gap: '20px' }}
21+
manual
22+
next={({ loading, fetch }) => (
23+
<div style={{ textAlign: 'center', padding: '20px' }}>
24+
<button onClick={fetch} disabled={loading}>
25+
{loading ? 'Loading...' : 'Load More Users 1'}
26+
</button>
27+
</div>
28+
)}
29+
>
30+
{users1.data.map((user) => (
31+
<UserCard key={user.id} user={user} />
32+
))}
33+
</InfiniteScroll>
34+
</div>
35+
36+
<div>
37+
<h2>Users 2</h2>
38+
<InfiniteScroll
39+
data="users2"
40+
style={{ display: 'grid', gap: '20px' }}
41+
manual
42+
next={({ loading, fetch }) => (
43+
<div style={{ textAlign: 'center', padding: '20px' }}>
44+
<button onClick={fetch} disabled={loading}>
45+
{loading ? 'Loading...' : 'Load More Users 2'}
46+
</button>
47+
</div>
48+
)}
49+
>
50+
{users2.data.map((user) => (
51+
<UserCard key={user.id} user={user} />
52+
))}
53+
</InfiniteScroll>
54+
</div>
55+
</div>
56+
</div>
57+
)
58+
}

packages/react/test-app/Pages/InfiniteScroll/ProgrammaticRef.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,33 @@
11
import { InfiniteScrollRef } from '@inertiajs/core'
22
import { InfiniteScroll } from '@inertiajs/react'
3-
import { useEffect, useRef, useState } from 'react'
3+
import { useEffect, useState } from 'react'
44
import UserCard, { User } from './UserCard'
55

66
export default ({ users }: { users: { data: User[] } }) => {
7-
const infRef = useRef<InfiniteScrollRef>(null)
7+
const [infRef, setInfRef] = useState<InfiniteScrollRef | null>(null)
88
const [hasPrevious, setHasMoreBefore] = useState(false)
99
const [hasNext, setHasMoreAfter] = useState(false)
1010

1111
const updateStates = () => {
12-
setHasMoreBefore(infRef.current?.hasPrevious() || false)
13-
setHasMoreAfter(infRef.current?.hasNext() || false)
12+
setHasMoreBefore(infRef?.hasPrevious() || false)
13+
setHasMoreAfter(infRef?.hasNext() || false)
1414
}
1515

1616
const fetchNext = () => {
17-
if (infRef.current) {
18-
infRef.current.fetchNext({ onFinish: updateStates })
17+
if (infRef) {
18+
infRef.fetchNext({ onFinish: updateStates })
1919
}
2020
}
2121

2222
const fetchPrevious = () => {
23-
if (infRef.current) {
24-
infRef.current.fetchPrevious({ onFinish: updateStates })
23+
if (infRef) {
24+
infRef.fetchPrevious({ onFinish: updateStates })
2525
}
2626
}
2727

2828
useEffect(() => {
2929
updateStates()
30-
}, [infRef.current])
30+
}, [infRef])
3131

3232
return (
3333
<div>
@@ -44,7 +44,7 @@ export default ({ users }: { users: { data: User[] } }) => {
4444
</div>
4545

4646
<InfiniteScroll
47-
ref={infRef}
47+
ref={setInfRef}
4848
data="users"
4949
style={{ display: 'grid', gap: '20px' }}
5050
manual
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<script lang="ts">
2+
import { InfiniteScroll } from '@inertiajs/svelte'
3+
import UserCard, { type User } from './UserCard.svelte'
4+
5+
export let users1: { data: User[] }
6+
export let users2: { data: User[] }
7+
</script>
8+
9+
<div style="padding: 20px">
10+
<h1>Dual Sibling InfiniteScroll</h1>
11+
<p style="margin-bottom: 20px">Two InfiniteScroll components side by side, sharing the window scroll</p>
12+
13+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 40px">
14+
<div>
15+
<h2>Users 1</h2>
16+
<InfiniteScroll data="users1" style="display: grid; gap: 20px" manual>
17+
{#each users1.data as user (user.id)}
18+
<UserCard {user} />
19+
{/each}
20+
21+
<div slot="next" let:exposedNext style="text-align: center; padding: 20px">
22+
<button on:click={exposedNext.fetch} disabled={exposedNext.loading}>
23+
{exposedNext.loading ? 'Loading...' : 'Load More Users 1'}
24+
</button>
25+
</div>
26+
</InfiniteScroll>
27+
</div>
28+
29+
<div>
30+
<h2>Users 2</h2>
31+
<InfiniteScroll data="users2" style="display: grid; gap: 20px" manual>
32+
{#each users2.data as user (user.id)}
33+
<UserCard {user} />
34+
{/each}
35+
36+
<div slot="next" let:exposedNext style="text-align: center; padding: 20px">
37+
<button on:click={exposedNext.fetch} disabled={exposedNext.loading}>
38+
{exposedNext.loading ? 'Loading...' : 'Load More Users 2'}
39+
</button>
40+
</div>
41+
</InfiniteScroll>
42+
</div>
43+
</div>
44+
</div>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<script setup lang="ts">
2+
import { InfiniteScroll } from '@inertiajs/vue3'
3+
import { User, default as UserCard } from './UserCard.vue'
4+
5+
defineProps<{
6+
users1: { data: User[] }
7+
users2: { data: User[] }
8+
}>()
9+
</script>
10+
11+
<template>
12+
<div style="padding: 20px">
13+
<h1>Dual Sibling InfiniteScroll</h1>
14+
<p style="margin-bottom: 20px">Two InfiniteScroll components side by side, sharing the window scroll</p>
15+
16+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 40px">
17+
<div>
18+
<h2>Users 1</h2>
19+
<InfiniteScroll data="users1" style="display: grid; gap: 20px" manual>
20+
<UserCard v-for="user in users1.data" :key="user.id" :user="user" />
21+
22+
<template #next="{ loading, fetch }">
23+
<div style="text-align: center; padding: 20px">
24+
<button @click="fetch" :disabled="loading">
25+
{{ loading ? 'Loading...' : 'Load More Users 1' }}
26+
</button>
27+
</div>
28+
</template>
29+
</InfiniteScroll>
30+
</div>
31+
32+
<div>
33+
<h2>Users 2</h2>
34+
<InfiniteScroll data="users2" style="display: grid; gap: 20px" manual>
35+
<UserCard v-for="user in users2.data" :key="user.id" :user="user" />
36+
37+
<template #next="{ loading, fetch }">
38+
<div style="text-align: center; padding: 20px">
39+
<button @click="fetch" :disabled="loading">
40+
{{ loading ? 'Loading...' : 'Load More Users 2' }}
41+
</button>
42+
</div>
43+
</template>
44+
</InfiniteScroll>
45+
</div>
46+
</div>
47+
</div>
48+
</template>

tests/app/server.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,51 @@ app.get('/infinite-scroll/dual-containers', (req, res) => {
10211021
partialReload ? 250 : 0,
10221022
)
10231023
})
1024+
app.get('/infinite-scroll/dual-sibling', (req, res) => {
1025+
const users1Page = req.query.users1 ? parseInt(req.query.users1) : 1
1026+
const users2Page = req.query.users2 ? parseInt(req.query.users2) : 1
1027+
const partialReload = !!req.headers['x-inertia-partial-data']
1028+
const shouldAppend = req.headers['x-inertia-infinite-scroll-merge-intent'] !== 'prepend'
1029+
1030+
const { paginated: users1Paginated, scrollProp: users1ScrollProp } = paginateUsers(
1031+
users1Page,
1032+
15,
1033+
40,
1034+
false,
1035+
'users1',
1036+
)
1037+
const { paginated: users2Paginated, scrollProp: users2ScrollProp } = paginateUsers(
1038+
users2Page,
1039+
15,
1040+
60,
1041+
false,
1042+
'users2',
1043+
)
1044+
1045+
const props = {}
1046+
const scrollProps = {}
1047+
1048+
if (!partialReload || req.headers['x-inertia-partial-data']?.includes('users1')) {
1049+
props.users1 = users1Paginated
1050+
scrollProps.users1 = users1ScrollProp
1051+
}
1052+
1053+
if (!partialReload || req.headers['x-inertia-partial-data']?.includes('users2')) {
1054+
props.users2 = users2Paginated
1055+
scrollProps.users2 = users2ScrollProp
1056+
}
1057+
1058+
setTimeout(
1059+
() =>
1060+
inertia.render(req, res, {
1061+
component: 'InfiniteScroll/DualSibling',
1062+
props,
1063+
[shouldAppend ? 'mergeProps' : 'prependProps']: ['users1.data', 'users2.data'],
1064+
scrollProps,
1065+
}),
1066+
partialReload ? 250 : 0,
1067+
)
1068+
})
10241069

10251070
function renderInfiniteScrollWithTag(req, res, component, total = 40, orderByDesc = false, perPage = 15) {}
10261071

0 commit comments

Comments
 (0)