collective

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

diff --git a/frontend/libs/components/generation-list/src/lib/useGalleryData.spec.tsx b/frontend/libs/components/generation-list/src/lib/useGalleryData.spec.tsxnew file mode 100644--- /dev/null+++ b/frontend/libs/components/generation-list/src/lib/useGalleryData.spec.tsx@@ -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" } })));+}diff --git a/frontend/libs/components/generation-list/src/lib/useGalleryData.ts b/frontend/libs/components/generation-list/src/lib/useGalleryData.ts--- a/frontend/libs/components/generation-list/src/lib/useGalleryData.ts+++ b/frontend/libs/components/generation-list/src/lib/useGalleryData.ts@@ -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);