Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
5a4ef27
wip
pascalbaljet Aug 28, 2025
77bfbe1
wip
pascalbaljet Aug 29, 2025
0b2d00e
wip
pascalbaljet Sep 3, 2025
a08fab6
tests
pascalbaljet Sep 3, 2025
7b92912
Fix code style
pascalbaljet Sep 3, 2025
a18da7d
wip
pascalbaljet Sep 3, 2025
2ab5990
Merge branch 'merge-improvements' into infinite-scrolling
pascalbaljet Sep 3, 2025
1635009
wip
pascalbaljet Sep 3, 2025
cf9b801
Merge branch 'master' into infinite-scrolling
pascalbaljet Sep 16, 2025
e4dd356
wip
pascalbaljet Sep 16, 2025
1e52f30
Svelte
pascalbaljet Sep 18, 2025
c436b38
Merge branch 'master' into merge-improvements
pascalbaljet Sep 18, 2025
9cdac25
Update InfiniteScroll.svelte
pascalbaljet Sep 18, 2025
5e42b10
CI fixes
pascalbaljet Sep 18, 2025
b64a7b3
cleanup
pascalbaljet Sep 23, 2025
9686f1c
Fix code style
pascalbaljet Sep 23, 2025
ab7343a
Update types.ts
pascalbaljet Sep 23, 2025
b197479
Merge branch 'master' into merge-improvements
pascalbaljet Sep 23, 2025
468392b
Improve prepend with matchOn
pascalbaljet Sep 23, 2025
2a65fbe
Merge branch 'merge-improvements' into infinite-scrolling
pascalbaljet Sep 23, 2025
f4aa64e
Cleanup, move `scrollProps` to `<InfiniteScroll>` branch
pascalbaljet Sep 23, 2025
1e4cc19
Merge branch 'master' into infinite-scrolling
pascalbaljet Sep 24, 2025
3f14281
Bump deps
pascalbaljet Sep 24, 2025
cc3f504
Update playground
pascalbaljet Sep 24, 2025
0061f25
Update elements.ts
pascalbaljet Sep 24, 2025
9948eb6
Fix code style
pascalbaljet Sep 24, 2025
12f56c8
Playground
pascalbaljet Sep 24, 2025
98a9dfb
Update conversation.json
pascalbaljet Sep 24, 2025
5c95b4c
Refactor
pascalbaljet Sep 24, 2025
f70b1dd
Added `hasPrevious` and `hasNext`
pascalbaljet Sep 25, 2025
3b55bc2
Vue bugfix
pascalbaljet Sep 25, 2025
cb6c9af
Update web.php
pascalbaljet Sep 25, 2025
46e47f2
Update DataTable.vue
pascalbaljet Sep 25, 2025
dbad35e
Keep triggering on short content
pascalbaljet Sep 25, 2025
0cf49a3
Impove scroll restoration
pascalbaljet Sep 25, 2025
7f4bf8c
wip
pascalbaljet Sep 25, 2025
9025beb
remove microstutter
pascalbaljet Sep 25, 2025
1de387a
Playground fixes
pascalbaljet Sep 25, 2025
83c724a
wip
pascalbaljet Sep 25, 2025
ceccf3d
Renamed `slotElement` to `itemsElement`
pascalbaljet Sep 25, 2025
e269991
Complex data structue test
pascalbaljet Sep 25, 2025
d000a74
Fix code style
pascalbaljet Sep 25, 2025
8983df3
wip
pascalbaljet Sep 26, 2025
f6110ca
wip
pascalbaljet Sep 26, 2025
a6868e4
wip
pascalbaljet Sep 26, 2025
73d5f69
wip
pascalbaljet Sep 26, 2025
337a8f3
Merge branch 'master' into merge-improvements
pascalbaljet Sep 26, 2025
267c8d7
Increase test object complexity
pascalbaljet Sep 26, 2025
d9885f6
Merge branch 'merge-improvements' into infinite-scrolling
pascalbaljet Sep 26, 2025
413dabe
Update ComplexMergeSelective.svelte
pascalbaljet Sep 26, 2025
b500e3b
Merge branch 'merge-improvements' into infinite-scrolling
pascalbaljet Sep 26, 2025
c2b5c8b
Update types.ts
pascalbaljet Sep 26, 2025
9b4f836
Merge branch 'master' into infinite-scrolling
pascalbaljet Sep 26, 2025
374e087
Merge branch 'master' into infinite-scrolling
pascalbaljet Sep 26, 2025
05012b0
cleanup
pascalbaljet Sep 26, 2025
8198d21
consistency
pascalbaljet Sep 26, 2025
de96705
playground cleanup
pascalbaljet Sep 26, 2025
653a9d8
wip
pascalbaljet Sep 26, 2025
31624e0
wip
pascalbaljet Sep 26, 2025
9593b89
wip
pascalbaljet Sep 26, 2025
302fe55
wip
pascalbaljet Sep 26, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,20 @@
"test:react": "PACKAGE=react playwright test",
"test:svelte": "PACKAGE=svelte playwright test",
"test:vue": "PACKAGE=vue3 playwright test",
"playground:react": "cd playgrounds/react && composer run dev",
"playground:svelte4": "cd playgrounds/svelte4 && composer run dev",
"playground:svelte5": "cd playgrounds/svelte5 && composer run dev",
"playground:vue": "cd playgrounds/vue3 && composer run dev",
"playground:react": "cd playgrounds/react && ./init.sh && composer run dev",
"playground:svelte4": "cd playgrounds/svelte4 && ./init.sh && composer run dev",
"playground:svelte5": "cd playgrounds/svelte5 && ./init.sh && composer run dev",
"playground:vue": "cd playgrounds/vue3 && ./init.sh && composer run dev",
"format": "prettier --write ."
},
"dependencies": {
"@playwright/test": "^1.55.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-svelte": "^3.2.3",
"prettier-plugin-tailwindcss": "^0.6.9"
"@playwright/test": "^1.55.1",
"prettier": "^3.6.2",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "^4.28.1"
"@rollup/rollup-linux-x64-gnu": "^4.52.2"
}
}
16 changes: 8 additions & 8 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,18 @@
},
"dependencies": {
"@types/lodash-es": "^4.17.12",
"axios": "^1.12.0",
"axios": "^1.12.2",
"lodash-es": "^4.17.21",
"qs": "^6.9.0"
"qs": "^6.14.0"
},
"devDependencies": {
"@types/deepmerge": "^2.2.0",
"@types/node": "^18.4",
"@types/nprogress": "^0.2.0",
"@types/qs": "^6.9.0",
"@types/deepmerge": "^2.2.3",
"@types/node": "^18.19.127",
"@types/nprogress": "^0.2.3",
"@types/qs": "^6.14.0",
"es-check": "^9.3.1",
"esbuild": "^0.25.0",
"esbuild-node-externals": "^1.6.0",
"esbuild": "^0.25.10",
"esbuild-node-externals": "^1.18.0",
"typescript": "^5.9.2"
}
}
57 changes: 57 additions & 0 deletions packages/core/src/domUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
const elementInViewport = (el: HTMLElement) => {
const rect = el.getBoundingClientRect()

// We check both vertically and horizontally for containers that scroll in either direction
const verticallyVisible = rect.top < window.innerHeight && rect.bottom >= 0
const horizontallyVisible = rect.left < window.innerWidth && rect.right >= 0

return verticallyVisible && horizontallyVisible
}

export const getScrollableParent = (element: HTMLElement | null): HTMLElement | null => {
let parent = element?.parentElement

while (parent) {
const overflowY = window.getComputedStyle(parent).overflowY

if (overflowY === 'auto' || overflowY === 'scroll') {
return parent
}

parent = parent.parentElement
}

return null
}

export const getElementsInViewportFromCollection = (
referenceElement: HTMLElement,
elements: HTMLElement[],
): HTMLElement[] => {
const referenceIndex = elements.indexOf(referenceElement)
const visibleElements: HTMLElement[] = []

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

if (elementInViewport(element)) {
visibleElements.push(element)
} else {
break
}
}

// Traverse downwards until an element is not visible
for (let i = referenceIndex + 1; i < elements.length; i++) {
const element = elements[i]

if (elementInViewport(element)) {
visibleElements.push(element)
} else {
break
}
}

return visibleElements
}
4 changes: 4 additions & 0 deletions packages/core/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export const fireInvalidEvent: GlobalEventTrigger<'invalid'> = (response) => {
return fireEvent('invalid', { cancelable: true, detail: { response } })
}

export const fireBeforeUpdateEvent: GlobalEventTrigger<'beforeUpdate'> = (page) => {
return fireEvent('beforeUpdate', { detail: { page } })
}

export const fireNavigateEvent: GlobalEventTrigger<'navigate'> = (page) => {
return fireEvent('navigate', { detail: { page } })
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Router } from './router'

export { getScrollableParent } from './domUtils'
export { objectToFormData } from './formData'
export { formDataToObject } from './formObject'
export { default as createHeadManager } from './head'
export { default as useInfiniteScroll } from './infiniteScroll'
export { shouldIntercept, shouldNavigate } from './navigationEvents'
export { hide as hideProgress, progress, reveal as revealProgress, default as setupProgress } from './progress'
export { resetFormFields } from './resetFormFields'
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/infiniteScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useInfiniteScrollData } from './infiniteScroll/data'
import { useInfiniteScrollElementManager } from './infiniteScroll/elements'
import { useInfiniteScrollQueryString } from './infiniteScroll/queryString'
import { useInfiniteScrollPreservation } from './infiniteScroll/scrollPreservation'
import { Page, ReloadOptions, UseInfiniteScrollOptions, UseInfiniteScrollProps } from './types'

/**
* Core infinite scroll composable that orchestrates data fetching, DOM management,
* scroll preservation, and URL synchronization.
*
* This is the main entry point that coordinates four sub-systems:
* - Data management: Handles pagination state and server requests
* - Element management: DOM observation and intersection detection
* - Query string sync: Updates URL as user scrolls through pages
* - Scroll preservation: Maintains scroll position during content updates
*/
export default function useInfiniteScroll(options: UseInfiniteScrollOptions): UseInfiniteScrollProps {
const queryStringManager = useInfiniteScrollQueryString({ ...options, getPageName: () => dataManager.getPageName() })

// Create scroll preservation callbacks that capture and restore scroll position
// and restore it after new content is prepended to maintain visual stability
const scrollPreservation = useInfiniteScrollPreservation(options)

const elementManager = useInfiniteScrollElementManager({
...options,
// As items enter viewport, update URL to reflect the most visible page
onItemIntersected: queryStringManager.onItemIntersected,
onPreviousTriggered: () => dataManager.fetchPrevious(),
onNextTriggered: () => dataManager.fetchNext(),
})

const dataManager = useInfiniteScrollData({
...options,
// Before updating page data, tag any manually added DOM elements
// so they don't get confused with server-loaded content
onBeforeUpdate: elementManager.processManuallyAddedElements,
// After successful request, tag new server content
onCompletePreviousRequest: (loadedPage?: string | number) => {
setTimeout(() => {
elementManager.processServerLoadedElements(loadedPage)
options.onCompletePreviousRequest()
window.queueMicrotask(elementManager.refreshTriggers)
})
},
onCompleteNextRequest: (loadedPage?: string | number) => {
setTimeout(() => {
elementManager.processServerLoadedElements(loadedPage)
options.onCompleteNextRequest()
window.queueMicrotask(elementManager.refreshTriggers)
})
},
})

const addScrollPreservationCallbacks = (reloadOptions: ReloadOptions): ReloadOptions => {
const { captureScrollPosition, restoreScrollPosition } = scrollPreservation.createCallbacks()

const originalOnBeforeUpdate = reloadOptions.onBeforeUpdate || (() => {})
const originalOnSuccess = reloadOptions.onSuccess || (() => {})

reloadOptions.onBeforeUpdate = (page: Page) => {
originalOnBeforeUpdate(page)
captureScrollPosition()
}

reloadOptions.onSuccess = (page: Page) => {
originalOnSuccess(page)
restoreScrollPosition()
}

return reloadOptions
}

const originalFetchNext = dataManager.fetchNext
dataManager.fetchNext = (reloadOptions: ReloadOptions = {}) => {
if (options.inReverseMode()) {
reloadOptions = addScrollPreservationCallbacks(reloadOptions)
}

originalFetchNext(reloadOptions)
}

const originalFetchPrevious = dataManager.fetchPrevious
dataManager.fetchPrevious = (reloadOptions: ReloadOptions = {}) => {
if (!options.inReverseMode()) {
reloadOptions = addScrollPreservationCallbacks(reloadOptions)
}

originalFetchPrevious(reloadOptions)
}

return {
dataManager,
elementManager,
}
}
111 changes: 111 additions & 0 deletions packages/core/src/infiniteScroll/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { router } from '../index'
import { page as currentPage } from '../page'
import { Page, PendingVisit, ReloadOptions, ScrollProp, UseInfiniteScrollDataManager } from '../types'

const MERGE_INTENT_HEADER = 'X-Inertia-Infinite-Scroll-Merge-Intent'

type Side = 'previous' | 'next'
type ScrollPropPageNames = keyof Pick<ScrollProp, 'previousPage' | 'nextPage'>

export const useInfiniteScrollData = (options: {
getPropName: () => string
onBeforeUpdate: () => void
onBeforePreviousRequest: () => void
onBeforeNextRequest: () => void
onCompletePreviousRequest: (loadedPage?: string | number) => void
onCompleteNextRequest: (loadedPage?: string | number) => void
}): UseInfiniteScrollDataManager => {
const getScrollPropFromCurrentPage = (): ScrollProp => {
const scrollProp = currentPage.get().scrollProps?.[options.getPropName()]

if (scrollProp) {
return scrollProp
}

throw new Error(`The page object does not contain a scroll prop named "${options.getPropName()}".`)
}

const { previousPage, nextPage, currentPage: lastLoadedPage } = getScrollPropFromCurrentPage()

const state = {
loading: false,
previousPage,
nextPage,
lastLoadedPage,
}

const getScrollPropKeyForSide = (side: Side): ScrollPropPageNames => {
return side === 'next' ? 'nextPage' : 'previousPage'
}

const findPageToLoad = (side: Side) => {
const pagePropName = getScrollPropKeyForSide(side)

return state[pagePropName]
}

const syncStateOnSuccess = (side: Side) => {
const scrollProp = getScrollPropFromCurrentPage()
const paginationProp = getScrollPropKeyForSide(side)

state.lastLoadedPage = scrollProp.currentPage
state[paginationProp] = scrollProp[paginationProp]
}

const getPageName = () => getScrollPropFromCurrentPage().pageName

const fetchPage = (side: Side, reloadOptions: ReloadOptions = {}): void => {
const page = findPageToLoad(side)

if (state.loading || page === null) {
return
}

state.loading = true

router.reload({
...reloadOptions,
data: { [getPageName()]: page },
only: [options.getPropName()],
preserveUrl: true, // we handle URL updates manually via useInfiniteScrollQueryString()
headers: {
[MERGE_INTENT_HEADER]: side === 'previous' ? 'prepend' : 'append',
...reloadOptions.headers,
},
onBefore: (visit: PendingVisit) => {
side === 'next' ? options.onBeforeNextRequest() : options.onBeforePreviousRequest()
reloadOptions.onBefore?.(visit)
},
onBeforeUpdate: (page: Page) => {
options.onBeforeUpdate()
reloadOptions.onBeforeUpdate?.(page)
},
onSuccess: (page: Page) => {
syncStateOnSuccess(side)
reloadOptions.onSuccess?.(page)
},
onFinish: (visit: any) => {
state.loading = false
side === 'next'
? options.onCompleteNextRequest(state.lastLoadedPage)
: options.onCompletePreviousRequest(state.lastLoadedPage)
reloadOptions.onFinish?.(visit)
},
})
}

const getLastLoadedPage = () => state.lastLoadedPage
const hasPrevious = () => !!state.previousPage
const hasNext = () => !!state.nextPage
const fetchPrevious = (reloadOptions?: ReloadOptions): void => fetchPage('previous', reloadOptions)
const fetchNext = (reloadOptions?: ReloadOptions): void => fetchPage('next', reloadOptions)

return {
getLastLoadedPage,
getPageName,
hasPrevious,
hasNext,
fetchNext,
fetchPrevious,
}
}
Loading