From 18715b5efd879b7b8f697c749c4d44a908f7f8d8 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 1 Sep 2026 16:30:06 +0200 Subject: [PATCH] fix(gallery): show a guest's own upload without a hard reload Correction to the QA root cause: the 304 is correct server behaviour, not a stale cache. The guest upload route answers 202 and queues the file, so the row lands as processing_status 'pending', and the photos list returns only completed rows. The immediate post-upload refetch therefore produces a byte-identical payload, express's body-derived weak ETag matches, and the browser is answered 304. Cache-busting would not have fixed it -- a busted request 200ms after the upload returns a 200 whose body still lacks the photo. The hard reload only worked because it happened seconds later. Poll instead: refetch immediately and every 2s until the photo count exceeds the pre-upload baseline, with a 60s deadline and cleanup on unmount. This also replaces two window.location.reload() callbacks, which could not have waited for the worker anyway and threw away scroll and folder state. Not done (out of scope, recommended follow-ups): GET /api/gallery/:slug/photos sets no cache headers at all for private per-guest data and relies on heuristic freshness -- noStoreCache.js already exists and would fit. And the guest upload flow has no progress signal, so the UI polls blind where a processing-status endpoint (or pending counts in the photos payload) would let it say "processing...". Refs testplan REPORT.md #12 (Part 4, P4-E.01). --- .../src/components/gallery/GalleryView.tsx | 44 ++++++++++++---- .../__tests__/galleryUploadRefresh.test.ts | 51 +++++++++++++++++++ 2 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 205b2239..06757d03 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect, useCallback } from 'react'; +import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { differenceInDays, parseISO } from 'date-fns'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -250,6 +250,37 @@ export const GalleryView: React.FC = ({ slug, event, requiresP return () => { timers.forEach(clearTimeout); clearInterval(interval); }; }, [hiddenUntilReveal, revealArmed, revealAtMs, refetch]); + // Post-upload refresh (P4-E.01). A guest upload is *queued*: the route + // answers 202 and the row lands as `processing_status: 'pending'`, while + // the photo list only returns completed rows. A single immediate refetch + // therefore comes back with a byte-identical payload (which the browser is + // answered with a 304), so the guest saw their upload silently vanish until + // they hard-reloaded. Poll for a short while until the queued photos finish + // processing instead of refetching — or reloading the page — exactly once. + const uploadRefreshTimerRef = useRef | null>(null); + const stopUploadRefresh = () => { + if (uploadRefreshTimerRef.current) { + clearInterval(uploadRefreshTimerRef.current); + uploadRefreshTimerRef.current = null; + } + }; + useEffect(() => stopUploadRefresh, []); + + const handleUploadComplete = () => { + setShowUploadModal(false); + const baseline = data?.photos?.length ?? 0; + const deadline = Date.now() + 60_000; + stopUploadRefresh(); + const poll = async () => { + const result = await refetch(); + if ((result.data?.photos?.length ?? 0) > baseline || Date.now() > deadline) { + stopUploadRefresh(); + } + }; + uploadRefreshTimerRef.current = setInterval(poll, 2000); + poll(); + }; + // Get individual protection settings from event const disableRightClick = data?.event?.disable_right_click === true; const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true; @@ -1356,10 +1387,7 @@ export const GalleryView: React.FC = ({ slug, event, requiresP { - setShowUploadModal(false); - window.location.reload(); - }} + onUploadComplete={handleUploadComplete} onClose={() => setShowUploadModal(false)} /> )} @@ -1766,11 +1794,7 @@ export const GalleryView: React.FC = ({ slug, event, requiresP { - setShowUploadModal(false); - // Refetch photos after upload - window.location.reload(); // Simple reload for now - }} + onUploadComplete={handleUploadComplete} onClose={() => setShowUploadModal(false)} /> )} diff --git a/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts b/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts new file mode 100644 index 00000000..2e1bcc85 --- /dev/null +++ b/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts @@ -0,0 +1,51 @@ +/** + * A guest upload must show up in the grid on its own. + * + * Guest uploads are queued: `POST /gallery/:id/upload` answers 202 and the row + * lands as `processing_status: 'pending'`, while `GET /gallery/:slug/photos` + * only returns completed rows. The old handler refetched exactly once (via a + * full `window.location.reload()`), which always raced the background worker — + * the payload was still byte-identical, the browser was answered 304, and the + * guest's photo silently vanished until they hard-reloaded (QA P4-E.01). + * + * GalleryView needs its providers, the router and a dozen child components to + * render, so this pins the contract at source level (same approach as + * facePreviewRendition.test.ts). + */ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const source = fs.readFileSync( + path.join(__dirname, '..', 'GalleryView.tsx'), + 'utf8' +); + +describe('post-upload photo refresh', () => { + it('never reloads the page to pick up an upload', () => { + expect(source).not.toContain('window.location.reload'); + }); + + it('keeps refetching until the queued photos appear', () => { + const handler = source.slice( + source.indexOf('const handleUploadComplete'), + source.indexOf('// Get individual protection settings') + ); + + expect(handler).toContain('await refetch()'); + expect(handler).toMatch(/setInterval\(poll/); + // Bounded: stop once the new photos land, and stop regardless after the + // deadline so a failed background job can't leave a poll running forever. + expect(handler).toContain('> baseline'); + expect(handler).toContain('Date.now() > deadline'); + }); + + it('wires the polling handler into the upload modals that render the grid', () => { + const wired = source.match(/onUploadComplete=\{handleUploadComplete\}/g) || []; + expect(wired.length).toBeGreaterThanOrEqual(2); + }); + + it('clears the poll when the gallery unmounts', () => { + expect(source).toContain('useEffect(() => stopUploadRefresh, [])'); + }); +});