perf(journey): render the gallery in pages instead of all at once

A long trip's gallery is hundreds of tiles and every one of them was in the DOM
from the first paint, which is what made scrolling it a chore (#1614). It now
renders 60 and grows as the sentinel comes into view — the same mechanism the
provider picker already uses, rather than a second one invented for this.

Deliberately not pagination over the wire: the gallery arrives inside the one
getJourneyFull payload that the client, the MCP tool, the MCP resource and the
PDF export all read, so cutting it there would change the answer for four
consumers at once and would silently shorten the book. The reported symptom was
the scrolling, and that is client-side.

The reported 500-photo limit is not in the code — no query limit, no upload cap,
no client slice — so nothing was raised.
This commit is contained in:
Maurice
2026-08-18 21:22:38 +02:00
committed by Maurice
parent 06b7f3e690
commit 2c333cb643
2 changed files with 62 additions and 1 deletions
@@ -1,6 +1,7 @@
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-016
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { act } from 'react'
import { http, HttpResponse } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
@@ -22,6 +23,31 @@ const uploadGalleryPhotos = vi.fn(
const JOURNEY_ID = 9
// The global IntersectionObserver stub never fires; this one hands the callback
// back so the gallery's load-more sentinel can be driven into view on demand.
const galleryObservers: { cb: IntersectionObserverCallback; el: Element | null }[] = []
function triggerIntersection() {
for (const o of galleryObservers) {
o.cb([{ isIntersecting: true, target: o.el } as unknown as IntersectionObserverEntry], null as never)
}
}
function stubIntersectionObserver() {
galleryObservers.length = 0
vi.stubGlobal('IntersectionObserver', class {
observe: (el: Element) => void
disconnect = vi.fn()
unobserve = vi.fn()
takeRecords = vi.fn(() => [])
constructor(cb: IntersectionObserverCallback) {
const entry: { cb: IntersectionObserverCallback; el: Element | null } = { cb, el: null }
galleryObservers.push(entry)
this.observe = (el: Element) => { entry.el = el }
}
})
}
function buildGalleryPhoto(overrides: Partial<GalleryPhoto> = {}): GalleryPhoto {
return {
id: 100,
@@ -303,4 +329,27 @@ describe('GalleryView', () => {
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Some photos failed to upload', 'error', undefined))
expect(onRefresh).not.toHaveBeenCalled()
})
// #1614 — a long trip's gallery is hundreds of tiles; rendering them all at once
// is what made scrolling it a chore.
it('FE-JRN-GALLERY-017: renders a first page and grows as the trigger comes into view', () => {
stubIntersectionObserver()
const many = Array.from({ length: 145 }, (_, i) => buildGalleryPhoto({ id: 100 + i, photo_id: 100 + i }))
const { container } = mountGallery(many)
const tiles = () => container.querySelectorAll('.aspect-square')
expect(tiles()).toHaveLength(60)
// The observer is stubbed in this suite, so drive the trigger the way it would.
act(() => { triggerIntersection() })
expect(tiles()).toHaveLength(120)
act(() => { triggerIntersection() })
expect(tiles()).toHaveLength(145)
})
it('FE-JRN-GALLERY-018: a gallery below one page shows no trigger at all', () => {
const { container } = mountGallery([buildGalleryPhoto(), buildGalleryPhoto({ id: 101, photo_id: 101 })])
expect(container.querySelectorAll('.aspect-square')).toHaveLength(2)
expect(container.querySelector('.animate-spin')).toBeNull()
})
})
@@ -10,6 +10,7 @@ import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
import { ProviderPicker } from './JourneyDetailPageProviderPicker'
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
import EmptyState from '../shared/EmptyState'
export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhotoClick, onRefresh, onRegisterUpload }: {
@@ -54,6 +55,14 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
}, [])
const allPhotos = gallery
// A long trip's gallery is hundreds of tiles, and rendering them all at once is
// what makes scrolling it a chore (#1614). The provider picker has grown its own
// way in for the same reason; this reuses it rather than inventing a second one.
const GALLERY_PAGE = 60
const [visibleCount, setVisibleCount] = useState(GALLERY_PAGE)
const shownPhotos = allPhotos.slice(0, visibleCount)
// Deleting or adding photos must not leave the window stranded past the end.
useEffect(() => { setVisibleCount(c => Math.min(Math.max(c, GALLERY_PAGE), Math.max(allPhotos.length, GALLERY_PAGE))) }, [allPhotos.length])
const entriesWithContent = entries.filter(e => e.type !== 'skeleton' || e.title)
@@ -143,7 +152,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
<EmptyState scene="journey" title={t('journey.detail.noPhotos')} />
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5 pb-24 md:pb-6">
{allPhotos.map((photo, i) => (
{shownPhotos.map((photo, i) => (
<div
key={photo.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
@@ -193,6 +202,9 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
))}
</div>
)}
{visibleCount < allPhotos.length && (
<ScrollTrigger onVisible={() => setVisibleCount(c => c + GALLERY_PAGE)} loading={false} />
)}
{/* Provider Photo Picker Modal */}
{showPicker && (