From 2c333cb643d74ac590ef7c538c3d3ba30c57a92d Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 18 Aug 2026 21:22:38 +0200 Subject: [PATCH] perf(journey): render the gallery in pages instead of all at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../JourneyDetailPageGalleryView.test.tsx | 49 +++++++++++++++++++ .../Journey/JourneyDetailPageGalleryView.tsx | 14 +++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx index 42b35bc94..87294107f 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.test.tsx @@ -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 { 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() + }) }) diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx index 016d5d81d..03dfb2b75 100644 --- a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx +++ b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx @@ -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 ) : (
- {allPhotos.map((photo, i) => ( + {shownPhotos.map((photo, i) => (
)} + {visibleCount < allPhotos.length && ( + setVisibleCount(c => c + GALLERY_PAGE)} loading={false} /> + )} {/* Provider Photo Picker Modal */} {showPicker && (