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).
This commit is contained in:
Paul Nothaft
2026-09-01 16:30:06 +02:00
parent 9d4bd7ab30
commit 18715b5efd
2 changed files with 85 additions and 10 deletions
+34 -10
View File
@@ -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<GalleryViewProps> = ({ 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<ReturnType<typeof setInterval> | 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<GalleryViewProps> = ({ slug, event, requiresP
<UserPhotoUpload
eventId={data?.event?.id || event?.id}
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
onUploadComplete={() => {
setShowUploadModal(false);
window.location.reload();
}}
onUploadComplete={handleUploadComplete}
onClose={() => setShowUploadModal(false)}
/>
)}
@@ -1766,11 +1794,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
<UserPhotoUpload
eventId={data?.event?.id || event?.id}
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
onUploadComplete={() => {
setShowUploadModal(false);
// Refetch photos after upload
window.location.reload(); // Simple reload for now
}}
onUploadComplete={handleUploadComplete}
onClose={() => setShowUploadModal(false)}
/>
)}
@@ -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, [])');
});
});