Skip to content

Commit fc1ad05

Browse files
authored
Preserve relative URL when <InfiniteScroll> updates query string (#2623)
* Preserve relative URL when updating query string * Test + refactor * React + Svelte * Refactor
1 parent 80d44a4 commit fc1ad05

File tree

8 files changed

+129
-12
lines changed

8 files changed

+129
-12
lines changed

packages/core/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@ export { shouldIntercept, shouldNavigate } from './navigationEvents'
99
export { hide as hideProgress, progress, reveal as revealProgress, default as setupProgress } from './progress'
1010
export { resetFormFields } from './resetFormFields'
1111
export * from './types'
12-
export { hrefToUrl, isUrlMethodPair, mergeDataIntoQueryString, urlWithoutHash } from './url'
12+
export {
13+
hrefToUrl,
14+
isUrlMethodPair,
15+
mergeDataIntoQueryString,
16+
urlHasProtocol,
17+
urlToString,
18+
urlWithoutHash,
19+
} from './url'
1320
export { type Router }
1421

1522
export const router = new Router()

packages/core/src/infiniteScroll/queryString.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { router } from '..'
1+
import { hrefToUrl, router, urlHasProtocol, urlToString } from '..'
22
import debounce from '../debounce'
33
import { getElementsInViewportFromCollection } from '../domUtils'
4+
import { page as currentPage } from './../page'
45
import Queue from './../queue'
56
import { getPageFromElement } from './elements'
67

@@ -9,6 +10,7 @@ const queue = new Queue<Promise<void>>()
910

1011
let initialUrl: URL | null
1112
let payloadUrl: URL | null
13+
let initialUrlWasAbsolute: boolean | null = null
1214

1315
/**
1416
* As users scroll through infinite content, this system updates the URL to reflect
@@ -32,8 +34,10 @@ export const useInfiniteScrollQueryString = (options: {
3234
}
3335

3436
if (!initialUrl || !payloadUrl) {
35-
initialUrl = new URL(window.location.href)
36-
payloadUrl = new URL(window.location.href)
37+
const currentPageUrl = currentPage.get().url
38+
initialUrl = hrefToUrl(currentPageUrl)
39+
payloadUrl = hrefToUrl(currentPageUrl)
40+
initialUrlWasAbsolute = urlHasProtocol(currentPageUrl)
3741
}
3842

3943
const pageName = options.getPageName()
@@ -50,16 +54,22 @@ export const useInfiniteScrollQueryString = (options: {
5054
})
5155
})
5256
.finally(() => {
53-
if (enabled && initialUrl && payloadUrl && initialUrl.href !== payloadUrl.href) {
57+
if (
58+
enabled &&
59+
initialUrl &&
60+
payloadUrl &&
61+
initialUrl.href !== payloadUrl.href &&
62+
initialUrlWasAbsolute !== null
63+
) {
5464
// Update URL without triggering a page reload or affecting scroll position
5565
router.replace({
56-
url: payloadUrl.toString(),
66+
url: urlToString(payloadUrl, initialUrlWasAbsolute),
5767
preserveScroll: true,
5868
preserveState: true,
5969
})
6070
}
6171

62-
initialUrl = payloadUrl = null
72+
initialUrl = payloadUrl = initialUrlWasAbsolute = null
6373
})
6474
}
6575

packages/core/src/url.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function mergeDataIntoQueryString<T extends RequestPayload>(
3939
qsArrayFormat: 'indices' | 'brackets' = 'brackets',
4040
): [string, MergeDataIntoQueryStringDataReturnType<T>] {
4141
const hasDataForQueryString = method === 'get' && !isFormData(data) && Object.keys(data).length > 0
42-
const hasHost = /^[a-z][a-z0-9+.-]*:\/\//i.test(href.toString())
42+
const hasHost = urlHasProtocol(href.toString())
4343
const hasAbsolutePath = hasHost || href.toString().startsWith('/') || href.toString() === ''
4444
const hasRelativePath = !hasAbsolutePath && !href.toString().startsWith('#') && !href.toString().startsWith('?')
4545
const hasRelativePathWithDotPrefix = /^[.]{1,2}([/]|$)/.test(href.toString())
@@ -90,3 +90,15 @@ export const isSameUrlWithoutHash = (url1: URL | Location, url2: URL | Location)
9090
export function isUrlMethodPair(href: unknown): href is UrlMethodPair {
9191
return href !== null && typeof href === 'object' && href !== undefined && 'url' in href && 'method' in href
9292
}
93+
94+
export function urlHasProtocol(url: string): boolean {
95+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(url)
96+
}
97+
98+
export function urlToString(url: URL | string, absolute: boolean): string {
99+
const urlObj = typeof url === 'string' ? hrefToUrl(url) : url
100+
101+
return absolute
102+
? `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}${urlObj.search}${urlObj.hash}`
103+
: `${urlObj.pathname}${urlObj.search}${urlObj.hash}`
104+
}

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
import { InfiniteScroll } from '@inertiajs/react'
1+
import { InfiniteScroll, usePage } from '@inertiajs/react'
22
import UserCard, { User } from './UserCard'
33

44
export default ({ users }: { users: { data: User[] } }) => {
5+
const page = usePage()
6+
7+
window.testing = {
8+
...(window.testing || {}),
9+
get pageUrl() {
10+
return page.url
11+
},
12+
}
13+
514
return (
615
<InfiniteScroll
716
data="users"

packages/svelte/test-app/Pages/InfiniteScroll/UpdateQueryString.svelte

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
<script lang="ts">
2-
import { InfiniteScroll } from '@inertiajs/svelte'
2+
import { InfiniteScroll, page } from '@inertiajs/svelte'
33
import UserCard, { type User } from './UserCard.svelte'
44
55
export let users: { data: User[] }
6+
7+
window.testing = {
8+
...(window.testing || {}),
9+
get pageUrl() {
10+
return $page.url
11+
},
12+
}
613
</script>
714

815
<InfiniteScroll data="users" style="display: grid; gap: 20px">

packages/vue3/test-app/Pages/InfiniteScroll/UpdateQueryString.vue

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
<script setup lang="ts">
2-
import { InfiniteScroll } from '@inertiajs/vue3'
2+
import { InfiniteScroll, usePage } from '@inertiajs/vue3'
33
import { User, default as UserCard } from './UserCard.vue'
44
55
defineProps<{
66
users: { data: User[] }
77
}>()
8+
9+
const page = usePage()
10+
11+
window.testing = {
12+
...(window.testing || {}),
13+
get pageUrl() {
14+
return page.url
15+
},
16+
}
817
</script>
918

1019
<template>

tests/app/helpers.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@ module.exports = {
2323
}
2424

2525
if (data.component.startsWith('InfiniteScroll')) {
26-
data.url = req.originalUrl
26+
// Support absolute URL format for testing URL preservation
27+
if (req.query.absolutePageUrl) {
28+
const protocol = req.protocol
29+
const host = req.get('host')
30+
data.url = `${protocol}://${host}${req.originalUrl}`
31+
} else {
32+
data.url = req.originalUrl
33+
}
2734
}
2835

2936
const partialDataHeader = req.headers['x-inertia-partial-data'] || ''

tests/infinite-scroll.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,62 @@ test.describe('URL query string management', () => {
12241224
await page.waitForTimeout(250)
12251225
expect(page.url()).toContain('page=2')
12261226
})
1227+
1228+
test('it preserves relative URL format when updating query string', async ({ page }) => {
1229+
requests.listen(page)
1230+
await page.goto('/infinite-scroll/update-query-string')
1231+
1232+
// Verify we start with a relative URL
1233+
const initialUrl = await page.evaluate(() => window.testing.pageUrl)
1234+
expect(initialUrl).toBe('/infinite-scroll/update-query-string')
1235+
expect(initialUrl).not.toContain('http')
1236+
1237+
await expect(page.getByText('User 1', { exact: true })).toBeVisible()
1238+
await expect(page.getByText('User 15')).toBeVisible()
1239+
1240+
await scrollToBottom(page)
1241+
await expect(page.getByText('Loading...')).toBeVisible()
1242+
await expect(page.getByText('User 16')).toBeVisible()
1243+
await expect(page.getByText('User 30')).toBeVisible()
1244+
await expect(page.getByText('Loading...')).toBeHidden()
1245+
1246+
await smoothScrollTo(page, await page.evaluate(() => document.body.scrollHeight))
1247+
await expectQueryString(page, '2')
1248+
1249+
// Verify the internal Inertia page URL is still relative
1250+
const updatedUrl = await page.evaluate(() => window.testing.pageUrl)
1251+
expect(updatedUrl).toContain('page=2')
1252+
expect(updatedUrl).not.toContain('http')
1253+
expect(updatedUrl).toMatch(/^\/infinite-scroll/)
1254+
})
1255+
1256+
test('it preserves absolute URL format when updating query string', async ({ page }) => {
1257+
requests.listen(page)
1258+
await page.goto('/infinite-scroll/update-query-string?absolutePageUrl=1')
1259+
1260+
// Verify we start with an absolute URL
1261+
const initialUrl = await page.evaluate(() => window.testing.pageUrl)
1262+
expect(initialUrl).toContain('http://localhost')
1263+
expect(initialUrl).toContain('/infinite-scroll/update-query-string')
1264+
1265+
await expect(page.getByText('User 1', { exact: true })).toBeVisible()
1266+
await expect(page.getByText('User 15')).toBeVisible()
1267+
1268+
await scrollToBottom(page)
1269+
await expect(page.getByText('Loading...')).toBeVisible()
1270+
await expect(page.getByText('User 16')).toBeVisible()
1271+
await expect(page.getByText('User 30')).toBeVisible()
1272+
await expect(page.getByText('Loading...')).toBeHidden()
1273+
1274+
await smoothScrollTo(page, await page.evaluate(() => document.body.scrollHeight))
1275+
await expectQueryString(page, '2')
1276+
1277+
// Verify the internal Inertia page URL is still absolute
1278+
const updatedUrl = await page.evaluate(() => window.testing.pageUrl)
1279+
expect(updatedUrl).toContain('page=2')
1280+
expect(updatedUrl).toContain('http://localhost')
1281+
expect(updatedUrl).toContain('/infinite-scroll/update-query-string')
1282+
})
12271283
})
12281284

12291285
test.describe('Scroll position preservation', () => {

0 commit comments

Comments
 (0)