Skip to content

Commit 08bc026

Browse files
fix(person-images): prevent flash of previous page's images (AOSSIE-Org#1315) (AOSSIE-Org#1321)
* fix(person-images): prevent flash of previous page's images (AOSSIE-Org#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. * fix(person-images): guard undefined clusterId and drop the any cast Addresses CodeRabbit review on the PR: only treat the slice as synced when clusterId is defined, and type the query-data fallback explicitly instead of casting through any. * refactor: clean up verbose comments * fix(person-images): prevent stale heading flash * ui consistency --------- Co-authored-by: ROHAN PANDEY <95585299+rohan-pandeyy@users.noreply.github.com>
1 parent 0d0cbec commit 08bc026

2 files changed

Lines changed: 197 additions & 14 deletions

File tree

frontend/src/pages/PersonImages/PersonImages.tsx

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export const PersonImages = () => {
2222
const images = useSelector(selectImages);
2323
const [clusterName, setClusterName] = useState<string>('random_name');
2424
const [isEditing, setIsEditing] = useState<boolean>(false);
25+
26+
const [loadedClusterId, setLoadedClusterId] = useState<string | undefined>(
27+
undefined,
28+
);
2529
const { data, isLoading, isSuccess, isError } = usePictoQuery({
2630
queryKey: ['person-images', clusterId],
2731
queryFn: async () => fetchClusterImages({ clusterId: clusterId || '' }),
@@ -42,9 +46,21 @@ export const PersonImages = () => {
4246
const images = (res?.images || []) as Image[];
4347
dispatch(setImages(images));
4448
setClusterName(res?.cluster_name || 'random_name');
49+
setLoadedClusterId(clusterId);
4550
dispatch(hideLoader());
4651
}
47-
}, [data, isSuccess, isError, isLoading, dispatch]);
52+
}, [data, isSuccess, isError, isLoading, dispatch, clusterId]);
53+
54+
// Fallback to query data until Redux slice syncs for this cluster (#1315)
55+
const personImages =
56+
clusterId !== undefined && loadedClusterId === clusterId
57+
? images
58+
: ((data?.data as { images?: Image[] })?.images ?? []);
59+
const displayName =
60+
clusterId !== undefined && loadedClusterId === clusterId
61+
? clusterName
62+
: ((data?.data as { cluster_name?: string })?.cluster_name ??
63+
'random_name');
4864

4965
const handleEditName = () => {
5066
setClusterName(clusterName);
@@ -67,8 +83,8 @@ export const PersonImages = () => {
6783
}
6884
};
6985
return (
70-
<div className="p-6">
71-
<div className="mb-6 flex items-center justify-between">
86+
<div>
87+
<div className="my-6 flex items-center justify-between">
7288
<Button
7389
variant="outline"
7490
onClick={() => navigate(`/${ROUTES.AI}`)}
@@ -107,21 +123,22 @@ export const PersonImages = () => {
107123
</Button>
108124
)}
109125
</div>
110-
<h1 className="mb-6 text-2xl font-bold">{clusterName}</h1>
111-
<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) => (
113-
<ImageCard
114-
key={image.id}
115-
image={image}
116-
imageIndex={index}
117-
className="w-full"
118-
onClick={() => dispatch(setCurrentViewIndex(index))}
119-
/>
126+
<h1 className="mb-6 text-2xl font-bold">{displayName}</h1>
127+
<div className="grid grid-cols-1 gap-4 pb-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
128+
{personImages.map((image, index) => (
129+
<div key={image.id} className="group relative">
130+
<ImageCard
131+
image={image}
132+
imageIndex={index}
133+
className="w-full transition-transform duration-200 group-hover:scale-105"
134+
onClick={() => dispatch(setCurrentViewIndex(index))}
135+
/>
136+
</div>
120137
))}
121138
</div>
122139

123140
{/* Media Viewer Modal */}
124-
{isImageViewOpen && <MediaView images={images} />}
141+
{isImageViewOpen && <MediaView images={personImages} />}
125142
</div>
126143
);
127144
};
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import { useLayoutEffect } from 'react';
2+
import { fireEvent, render, screen, waitFor } from '@/test-utils';
3+
import { Routes, Route, useNavigate } from 'react-router';
4+
import { PersonImages } from '@/pages/PersonImages/PersonImages';
5+
import type { Image } from '@/types/Media';
6+
import * as apiFunctions from '@/api/api-functions';
7+
8+
jest.mock('@tauri-apps/api/core', () => ({
9+
invoke: jest.fn().mockResolvedValue(null),
10+
convertFileSrc: (path: string) => path,
11+
}));
12+
13+
jest.mock('@/api/api-functions', () => ({
14+
fetchClusterImages: jest.fn(),
15+
renameCluster: jest.fn(),
16+
}));
17+
18+
const fetchClusterImages = apiFunctions.fetchClusterImages as jest.Mock;
19+
20+
const makeImage = (id: string, path: string): Image => ({
21+
id,
22+
path,
23+
thumbnailPath: path,
24+
folder_id: 'folder',
25+
isTagged: true,
26+
isFavourite: false,
27+
tags: [],
28+
});
29+
30+
const personAImages = [
31+
makeImage('a1', '/personA/1.jpg'),
32+
makeImage('a2', '/personA/2.jpg'),
33+
];
34+
const personBImages = [
35+
makeImage('b1', '/personB/1.jpg'),
36+
makeImage('b2', '/personB/2.jpg'),
37+
];
38+
39+
const CommitProbe = ({
40+
onCommit,
41+
}: {
42+
onCommit?: (headingText: string | null) => void;
43+
}) => {
44+
useLayoutEffect(() => {
45+
onCommit?.(document.querySelector('h1')?.textContent ?? null);
46+
});
47+
48+
return null;
49+
};
50+
51+
const PersonImagesWithNavigation = ({
52+
onCommit,
53+
}: {
54+
onCommit?: (headingText: string | null) => void;
55+
}) => {
56+
const navigate = useNavigate();
57+
58+
return (
59+
<>
60+
<button type="button" onClick={() => navigate('/person/B')}>
61+
Go to Person B
62+
</button>
63+
<PersonImages />
64+
<CommitProbe onCommit={onCommit} />
65+
</>
66+
);
67+
};
68+
69+
const renderPerson = (
70+
clusterId: string,
71+
options: {
72+
withNavigation?: boolean;
73+
onCommit?: (headingText: string | null) => void;
74+
} = {},
75+
) =>
76+
render(
77+
<Routes>
78+
<Route
79+
path="/person/:clusterId"
80+
element={
81+
options.withNavigation ? (
82+
<PersonImagesWithNavigation onCommit={options.onCommit} />
83+
) : (
84+
<PersonImages />
85+
)
86+
}
87+
/>
88+
</Routes>,
89+
{
90+
preloadedState: {
91+
images: { images: personAImages, currentViewIndex: -1 },
92+
},
93+
initialRoutes: [`/person/${clusterId}`],
94+
},
95+
);
96+
97+
const imageSrcs = (container: HTMLElement) =>
98+
Array.from(container.querySelectorAll('img')).map((img) =>
99+
img.getAttribute('src'),
100+
);
101+
102+
const hasPersonA = (container: HTMLElement) =>
103+
imageSrcs(container).some((src) => src?.startsWith('/personA/'));
104+
const hasPersonB = (container: HTMLElement) =>
105+
imageSrcs(container).some((src) => src?.startsWith('/personB/'));
106+
107+
beforeEach(() => {
108+
fetchClusterImages.mockReset();
109+
fetchClusterImages.mockImplementation(async ({ clusterId }) => ({
110+
success: true,
111+
data: {
112+
images: clusterId === 'B' ? personBImages : personAImages,
113+
cluster_name: clusterId === 'B' ? 'Bob' : 'Alice',
114+
},
115+
}));
116+
});
117+
118+
describe('PersonImages (issue #1315: no flash of the previous page)', () => {
119+
test('never paints stale images or heading when navigating to a cached person', async () => {
120+
const committedHeadings: Array<string | null> = [];
121+
const { container, queryClient } = renderPerson('A', {
122+
withNavigation: true,
123+
onCommit: (headingText) => committedHeadings.push(headingText),
124+
});
125+
126+
await waitFor(() => {
127+
expect(
128+
screen.getByRole('heading', { name: 'Alice' }),
129+
).toBeInTheDocument();
130+
expect(hasPersonA(container)).toBe(true);
131+
});
132+
133+
queryClient.setQueryData(['person-images', 'B'], {
134+
success: true,
135+
data: {
136+
images: personBImages,
137+
cluster_name: 'Bob',
138+
},
139+
});
140+
141+
committedHeadings.length = 0;
142+
fireEvent.click(screen.getByRole('button', { name: 'Go to Person B' }));
143+
144+
expect(committedHeadings).not.toContain('Alice');
145+
expect(
146+
screen.queryByRole('heading', { name: 'Alice' }),
147+
).not.toBeInTheDocument();
148+
expect(screen.getByRole('heading', { name: 'Bob' })).toBeInTheDocument();
149+
expect(hasPersonA(container)).toBe(false);
150+
expect(hasPersonB(container)).toBe(true);
151+
});
152+
153+
test("renders the navigated cluster's images and name once loaded", async () => {
154+
const { container } = renderPerson('B');
155+
156+
expect(
157+
screen.queryByRole('heading', { name: 'Alice' }),
158+
).not.toBeInTheDocument();
159+
expect(hasPersonA(container)).toBe(false);
160+
161+
expect(await screen.findByText('Bob')).toBeInTheDocument();
162+
await waitFor(() => {
163+
expect(hasPersonB(container)).toBe(true);
164+
});
165+
});
166+
});

0 commit comments

Comments
 (0)