Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 21 additions & 12 deletions web/components/common/InfiniteScrollSentinel.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { findScrollableAncestor } from '@/utils/scrollableAncestor'

type InfiniteScrollSentinelProps = {
onLoadMore: () => void,
hasMore: boolean,
isFetchingMore: boolean,
/** Distance from the viewport at which loading kicks in. Defaults to 800px. */
/** Distance from the scroll container edge at which loading kicks in. Defaults to 800px. */
rootMargin?: string,
className?: string,
}

/**
* Invisible marker that triggers `onLoadMore` as it approaches the viewport.
* The generous root margin loads the next window before the user reaches the
* bottom, which keeps scrolling smooth on touch devices. Intersection state is
* tracked so loading continues even when the marker stays in view after a page
* resolves.
* Invisible marker that triggers `onLoadMore` as it approaches the bottom of the
* scroll container. The observer is rooted at the nearest scrollable ancestor
* (falling back to the viewport) so the generous root margin actually loads the
* next window *before* the user reaches the bottom — when the target is rooted at
* the viewport instead, an intervening `overflow-y-auto` container clips the
* sentinel and the margin is ignored. Intersection state is tracked so loading
* continues even when the marker stays in view after a page resolves (e.g. when
* a page is shorter than the viewport).
*/
export function InfiniteScrollSentinel({
onLoadMore,
Expand All @@ -23,22 +27,25 @@ export function InfiniteScrollSentinel({
rootMargin = '800px',
className,
}: InfiniteScrollSentinelProps) {
const ref = useRef<HTMLDivElement | null>(null)
// A callback ref (state) re-runs the observer effect whenever the sentinel
// mounts or unmounts. `hasMore` toggling false→true remounts the node with a
// fresh element, and this keeps the observer attached to the live node instead
// of a detached one.
const [node, setNode] = useState<HTMLDivElement | null>(null)
const [isIntersecting, setIsIntersecting] = useState(false)

useEffect(() => {
const node = ref.current
if (!node || typeof IntersectionObserver === 'undefined') return
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry) setIsIntersecting(entry.isIntersecting)
},
{ rootMargin }
{ root: findScrollableAncestor(node), rootMargin }
)
observer.observe(node)
return () => observer.disconnect()
}, [rootMargin])
}, [node, rootMargin])

const onLoadMoreRef = useRef(onLoadMore)
onLoadMoreRef.current = onLoadMore
Expand All @@ -49,7 +56,9 @@ export function InfiniteScrollSentinel({
}
}, [isIntersecting, hasMore, isFetchingMore])

const setRef = useCallback((el: HTMLDivElement | null) => setNode(el), [])

if (!hasMore) return null

return <div ref={ref} aria-hidden className={className} />
return <div ref={setRef} aria-hidden className={className} />
}
17 changes: 17 additions & 0 deletions web/utils/scrollableAncestor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { isScrollableOverflowY } from './scrollableAncestor'

describe('isScrollableOverflowY', () => {
it('treats auto, scroll and overlay as scrollable', () => {
expect(isScrollableOverflowY('auto')).toBe(true)
expect(isScrollableOverflowY('scroll')).toBe(true)
expect(isScrollableOverflowY('overlay')).toBe(true)
})

it('treats visible, hidden and clip as non-scrollable', () => {
expect(isScrollableOverflowY('visible')).toBe(false)
expect(isScrollableOverflowY('hidden')).toBe(false)
expect(isScrollableOverflowY('clip')).toBe(false)
expect(isScrollableOverflowY('')).toBe(false)
})
})
34 changes: 34 additions & 0 deletions web/utils/scrollableAncestor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Helpers for locating the scroll container an element actually lives in.
*
* Infinite-scroll relies on an IntersectionObserver. When `root` is left as the
* viewport but the content is rendered inside a nested `overflow-y-auto`
* container (as the app shell does in `Page`), intermediate scroll containers
* clip the observed target, so a generous `rootMargin` lookahead is silently
* ignored — the sentinel only intersects once it reaches the very bottom.
* Observing against the real scroll container restores the "load before the
* bottom is reached" behaviour.
*/

/** A computed `overflow-y` value that lets the element scroll its content. */
export function isScrollableOverflowY(overflowY: string): boolean {
return overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'
}

/**
* Walks up from `node` and returns the nearest ancestor that scrolls vertically.
* Stops at (and ignores) the document root, where `root: null` (the viewport) is
* already the correct IntersectionObserver root. Returns `null` when no nested
* scroll container exists, which the caller should treat as "use the viewport".
*/
export function findScrollableAncestor(node: Element | null): HTMLElement | null {
if (typeof window === 'undefined') return null
let current = node?.parentElement ?? null
while (current && current !== document.body && current !== document.documentElement) {
if (isScrollableOverflowY(window.getComputedStyle(current).overflowY)) {
return current
}
current = current.parentElement
}
return null
}
Loading