Browse / github.com/storytold/artcraft / patch
fix(gallery): ignore responses superseded by a reset
fix(gallery): ignore responses superseded by a reset ## Problem The desktop image, video and audio create pages use `useGalleryData` for completed history. Resetting the hook for a username/filter change or refresh releases its in-flight loading guard, but does not invalidate the previous request. An older response can therefore replace the refreshed feed or clear the loading flags of the newer request. ## Change Track ownership of the current history load. Invalidate old responses on resets, refreshes and cleanup; only the current generation may apply results or release loading flags. Also clear the loading state when the username becomes un
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- rudycelekli (imported from pull request #2080, open)
- Base
b5614ad333839649d8f4e779d955b89b3c22f376- Size
- +120 −3 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
b1642e6d60786e1ca6215d9a
How it went
No other operator has reported using it yet.
Diff
raw · frontend/libs/components/generation-list/src/lib/useGalleryData.spec.tsx frontend/libs/components/generation-list/src/lib/useGalleryData.ts
new file mode 100644@@ -0,0 +1,105 @@+import { act } from "react";+import { createRoot, type Root } from "react-dom/client";+import { FilterMediaClasses } from "@storyteller/api";+import { useGalleryData } from "./useGalleryData";++let root: Root;+let container: HTMLDivElement;+let current: ReturnType<typeof useGalleryData>;+let requests: Array<{ url: URL; resolve: (response: Response) => void }>;++beforeEach(() => {+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);+ requests = [];+ vi.stubGlobal("fetch", vi.fn((url: string) => new Promise<Response>((resolve) => {+ requests.push({ url: new URL(url), resolve });+ })));+ container = document.createElement("div");+ document.body.append(container);+ root = createRoot(container);+});++afterEach(async () => {+ await act(async () => root.unmount());+ container.remove();+ vi.unstubAllGlobals();+});++it("keeps the current username's history when an older request finishes last", async () => {+ await render("old");+ await render("new");+ expect(requests.map(({ url }) => url.pathname)).toEqual([+ "/v1/media_files/list/user/old",+ "/v1/media_files/list/user/new",+ ]);+ await complete(1, "new-result");+ await complete(0, "old-result");+ expect(current.items.map((item) => item.id)).toEqual(["new-result"]);+});++it("keeps the current request loading when an older request finishes first", async () => {+ await render("old");+ await render("new");+ await complete(0, "old-result");+ expect(current.isLoading).toBe(true);+ expect(current.isInitialLoading).toBe(true);+ expect(current.items).toEqual([]);+ await complete(1, "new-result");+ expect(current.isLoading).toBe(false);+});++it("keeps an empty logged-out history when the previous request finishes", async () => {+ await render("old");+ await render(null);+ const loadingAfterLogout = current.isLoading;+ await complete(0, "old-result");+ expect(current.items).toEqual([]);+ expect(current.hasMore).toBe(false);+ expect(loadingAfterLogout).toBe(false);+});++it("keeps refreshed history when the pre-refresh response finishes last", async () => {+ await render("user");+ await act(async () => current.refresh());+ expect(requests).toHaveLength(2);+ await complete(1, "refreshed-result");+ await complete(0, "old-result");+ expect(current.items.map((item) => item.id)).toEqual(["refreshed-result"]);+});++it("still appends the next page and blocks duplicate concurrent load-more requests", async () => {+ await render("user");+ await complete(0, "first", 0, 2);+ await act(async () => { current.loadMore(); current.loadMore(); });+ expect(requests).toHaveLength(2);+ expect(requests[1].url.searchParams.get("page_index")).toBe("1");+ await complete(1, "second", 1, 2);+ expect(current.items.map((item) => item.id)).toEqual(["first", "second"]);+ expect(current.hasMore).toBe(false);+});++it("still finishes loading when the current API request fails", async () => {+ await render("user");+ await act(async () => requests[0].resolve(new Response("error", { status: 500 })));+ expect(current.isLoading).toBe(false);+ expect(current.isInitialLoading).toBe(false);+ expect(current.items).toEqual([]);+});++async function render(username: string | null) {+ // Reuse the component type so a username change exercises the hook's reset.+ await act(async () => root.render(<GalleryHarness username={username} />));+}++function GalleryHarness({ username }: { username: string | null }) {+ current = useGalleryData({ username, filterMediaClasses: [FilterMediaClasses.IMAGE] });+ return null;+}++async function complete(index: number, token: string, page = 0, total = 1) {+ await act(async () => requests[index].resolve(new Response(JSON.stringify({+ success: true,+ results: [{ token, media_class: "image", created_at: "2026-10-10T00:00:00Z", media_links: { cdn_url: `https://example.com/${token}.png` } }],+ pagination: { current: page, total_page_count: total },+ }), { status: 200, headers: { "Content-Type": "application/json" } })));+}@@ -68,6 +68,7 @@ export function useGalleryData(options: { const [pageIndex, setPageIndex] = useState(0); const [hasMore, setHasMore] = useState(true); const isLoadingRef = useRef(false);+ const generationRef = useRef(0); const api = useMemo(() => new GalleryModalApi(), []); @@ -108,6 +109,7 @@ export function useGalleryData(options: { if (!username) return; if (isLoadingRef.current) return; isLoadingRef.current = true;+ const generation = generationRef.current; setIsLoading(true); try {@@ -119,6 +121,8 @@ export function useGalleryData(options: { page_size: PAGE_SIZE, }); + if (generation !== generationRef.current) return;+ if (response.success && response.data) { const modelIdSet = filterModelIds && filterModelIds.length@@ -157,9 +161,11 @@ export function useGalleryData(options: { // ignore } - setIsLoading(false);- setIsInitialLoading(false);- isLoadingRef.current = false;+ if (generation === generationRef.current) {+ setIsLoading(false);+ setIsInitialLoading(false);+ isLoadingRef.current = false;+ } }, [ username,@@ -175,6 +181,8 @@ export function useGalleryData(options: { // Initial load + filter change. When logged out (no username), clear the // loading flag so the shell renders the empty state instead of a spinner. useEffect(() => {+ generationRef.current++;+ setIsLoading(false); setItems([]); setPageIndex(0); isLoadingRef.current = false;@@ -186,6 +194,9 @@ export function useGalleryData(options: { setHasMore(true); setIsInitialLoading(true); loadItems(true);+ return () => {+ generationRef.current++;+ }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [username, JSON.stringify(filterMediaClasses), JSON.stringify(filterModelIds)]); @@ -196,6 +207,7 @@ export function useGalleryData(options: { }, [hasMore, loadItems]); const refresh = useCallback(() => {+ generationRef.current++; setItems([]); setPageIndex(0); setHasMore(true);