Skip to content

Commit c85b57f

Browse files
committed
fix(person-images): prevent flash of previous page's images (#1315)
Scope the grid to the active cluster so the shared `images` slice left by the previous page can no longer paint before the person's photos load. Track which cluster the slice holds via `loadedClusterId`; until it syncs, render from the `['person-images', clusterId]` query data, which is always scoped to the active id. Reading the slice once synced keeps the optimistic favourite toggle working. Adds a regression test that fails when the previous page's images render.
1 parent 0d0cbec commit c85b57f

2 files changed

Lines changed: 119 additions & 3 deletions

File tree

frontend/src/pages/PersonImages/PersonImages.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export const PersonImages = () => {
2222
const images = useSelector(selectImages);
2323
const [clusterName, setClusterName] = useState<string>('random_name');
2424
const [isEditing, setIsEditing] = useState<boolean>(false);
25+
// Which cluster the shared `images` slice currently holds. Keyed on clusterId
26+
// (not `isLoading`) so it also covers person A -> person B when B is cached.
27+
const [loadedClusterId, setLoadedClusterId] = useState<string | undefined>(
28+
undefined,
29+
);
2530
const { data, isLoading, isSuccess, isError } = usePictoQuery({
2631
queryKey: ['person-images', clusterId],
2732
queryFn: async () => fetchClusterImages({ clusterId: clusterId || '' }),
@@ -42,9 +47,18 @@ export const PersonImages = () => {
4247
const images = (res?.images || []) as Image[];
4348
dispatch(setImages(images));
4449
setClusterName(res?.cluster_name || 'random_name');
50+
setLoadedClusterId(clusterId);
4551
dispatch(hideLoader());
4652
}
47-
}, [data, isSuccess, isError, isLoading, dispatch]);
53+
}, [data, isSuccess, isError, isLoading, dispatch, clusterId]);
54+
55+
// Until the slice is synced for this cluster it still holds the previous
56+
// page's images (issue #1315). Fall back to the cluster-scoped query data for
57+
// that window; read the slice once synced so favourite toggles still work.
58+
const personImages =
59+
loadedClusterId === clusterId
60+
? images
61+
: (((data?.data as any)?.images as Image[]) ?? []);
4862

4963
const handleEditName = () => {
5064
setClusterName(clusterName);
@@ -109,7 +123,7 @@ export const PersonImages = () => {
109123
</div>
110124
<h1 className="mb-6 text-2xl font-bold">{clusterName}</h1>
111125
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
112-
{images.map((image, index) => (
126+
{personImages.map((image, index) => (
113127
<ImageCard
114128
key={image.id}
115129
image={image}
@@ -121,7 +135,7 @@ export const PersonImages = () => {
121135
</div>
122136

123137
{/* Media Viewer Modal */}
124-
{isImageViewOpen && <MediaView images={images} />}
138+
{isImageViewOpen && <MediaView images={personImages} />}
125139
</div>
126140
);
127141
};
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { render, screen, waitFor } from '@/test-utils';
2+
import { Routes, Route } from 'react-router';
3+
import { PersonImages } from '@/pages/PersonImages/PersonImages';
4+
import type { Image } from '@/types/Media';
5+
import * as apiFunctions from '@/api/api-functions';
6+
7+
// ImageCard resolves thumbnails through Tauri's convertFileSrc; return the path
8+
// unchanged so we can assert which person's images are in the DOM by their src.
9+
jest.mock('@tauri-apps/api/core', () => ({
10+
invoke: jest.fn().mockResolvedValue(null),
11+
convertFileSrc: (path: string) => path,
12+
}));
13+
14+
jest.mock('@/api/api-functions', () => ({
15+
fetchClusterImages: jest.fn(),
16+
renameCluster: jest.fn(),
17+
}));
18+
19+
const fetchClusterImages = apiFunctions.fetchClusterImages as jest.Mock;
20+
21+
const makeImage = (id: string, path: string): Image => ({
22+
id,
23+
path,
24+
thumbnailPath: path,
25+
folder_id: 'folder',
26+
isTagged: true,
27+
isFavourite: false,
28+
tags: [],
29+
});
30+
31+
// "Person A" stands in for whatever the previous page (e.g. the Home gallery)
32+
// left in the shared `images` slice; "Person B" is the cluster we navigate to.
33+
const personAImages = [
34+
makeImage('a1', '/personA/1.jpg'),
35+
makeImage('a2', '/personA/2.jpg'),
36+
];
37+
const personBImages = [
38+
makeImage('b1', '/personB/1.jpg'),
39+
makeImage('b2', '/personB/2.jpg'),
40+
];
41+
42+
const renderPerson = (clusterId: string) =>
43+
render(
44+
<Routes>
45+
<Route path="/person/:clusterId" element={<PersonImages />} />
46+
</Routes>,
47+
{
48+
// Pre-seed the shared slice with the previous page's images. This is the
49+
// exact condition that produced the flash described in issue #1315.
50+
preloadedState: {
51+
images: { images: personAImages, currentViewIndex: -1 },
52+
},
53+
initialRoutes: [`/person/${clusterId}`],
54+
},
55+
);
56+
57+
const imageSrcs = (container: HTMLElement) =>
58+
Array.from(container.querySelectorAll('img')).map((img) =>
59+
img.getAttribute('src'),
60+
);
61+
62+
const hasPersonA = (container: HTMLElement) =>
63+
imageSrcs(container).some((src) => src?.startsWith('/personA/'));
64+
const hasPersonB = (container: HTMLElement) =>
65+
imageSrcs(container).some((src) => src?.startsWith('/personB/'));
66+
67+
beforeEach(() => {
68+
fetchClusterImages.mockReset();
69+
fetchClusterImages.mockImplementation(async ({ clusterId }) => ({
70+
success: true,
71+
data: {
72+
images: clusterId === 'B' ? personBImages : personAImages,
73+
cluster_name: clusterId === 'B' ? 'Bob' : 'Alice',
74+
},
75+
}));
76+
});
77+
78+
describe('PersonImages (issue #1315: no flash of the previous page)', () => {
79+
test('never paints the stale slice images, on the first frame or after', async () => {
80+
const { container } = renderPerson('B');
81+
82+
// First frame: before the cluster query resolves, the shared slice still
83+
// holds Person A's images. The grid must not paint them, otherwise the user
84+
// sees the flash. (This assertion fails against the pre-fix code.)
85+
expect(hasPersonA(container)).toBe(false);
86+
87+
// Let the query resolve, then confirm Person A never appears at any point.
88+
await waitFor(() => {
89+
expect(hasPersonB(container)).toBe(true);
90+
});
91+
expect(hasPersonA(container)).toBe(false);
92+
});
93+
94+
test("renders the navigated cluster's images and name once loaded", async () => {
95+
const { container } = renderPerson('B');
96+
97+
expect(await screen.findByText('Bob')).toBeInTheDocument();
98+
await waitFor(() => {
99+
expect(hasPersonB(container)).toBe(true);
100+
});
101+
});
102+
});

0 commit comments

Comments
 (0)