From af75c5f39d6ed132d89eb735d256183e01784b0f Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Tue, 12 May 2026 12:25:48 -0500 Subject: [PATCH] fix: Infinite API request loop on empty stores (#7613) --- .../partials/use-actions-factory.test.ts | 28 +++++++++++++++++-- .../partials/use-actions-factory.ts | 4 +++ .../composables/partials/use-store-factory.ts | 12 +++++--- .../composables/store/use-category-store.ts | 8 ++++-- .../composables/store/use-cookbook-store.ts | 8 ++++-- .../app/composables/store/use-food-store.ts | 8 ++++-- .../composables/store/use-household-store.ts | 8 ++++-- .../app/composables/store/use-label-store.ts | 4 ++- .../app/composables/store/use-tag-store.ts | 8 ++++-- .../app/composables/store/use-tool-store.ts | 8 ++++-- .../app/composables/store/use-unit-store.ts | 4 ++- .../app/composables/store/use-user-store.ts | 4 ++- 12 files changed, 82 insertions(+), 22 deletions(-) diff --git a/frontend/app/composables/partials/use-actions-factory.test.ts b/frontend/app/composables/partials/use-actions-factory.test.ts index e5ff546d5..08c6c291e 100644 --- a/frontend/app/composables/partials/use-actions-factory.test.ts +++ b/frontend/app/composables/partials/use-actions-factory.test.ts @@ -13,9 +13,10 @@ describe("useStoreActions", () => { const mockStore = ref([]); const mockLoading = ref(false); + const mockInitialized = ref(false); test("deleteMany calls deleteOne for each ID and refreshes once", async () => { - const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading); + const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading, mockInitialized); mockApi.deleteOne = vi.fn().mockResolvedValue({ response: { data: {} } }); mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } }); @@ -32,7 +33,7 @@ describe("useStoreActions", () => { }); test("deleteMany handles empty array", async () => { - const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading); + const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading, mockInitialized); mockApi.deleteOne = vi.fn(); mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } }); @@ -44,7 +45,7 @@ describe("useStoreActions", () => { }); test("deleteMany sets loading state", async () => { - const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading); + const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading, mockInitialized); mockApi.deleteOne = vi.fn().mockResolvedValue({}); mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } }); @@ -55,4 +56,25 @@ describe("useStoreActions", () => { await promise; expect(mockLoading.value).toBe(false); }); + + test("refresh sets initialized to true even when store returns empty results", async () => { + const localInitialized = ref(false); + const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading, localInitialized); + + mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [] } }); + + expect(localInitialized.value).toBe(false); + await actions.refresh(); + expect(localInitialized.value).toBe(true); + }); + + test("refresh sets initialized to true when store returns items", async () => { + const localInitialized = ref(false); + const actions = useStoreActions("test-store", mockApi, mockStore, mockLoading, localInitialized); + + mockApi.getAll = vi.fn().mockResolvedValue({ data: { items: [{ id: "1", name: "item" }] } }); + + await actions.refresh(); + expect(localInitialized.value).toBe(true); + }); }); diff --git a/frontend/app/composables/partials/use-actions-factory.ts b/frontend/app/composables/partials/use-actions-factory.ts index b9e57a305..fe29274d7 100644 --- a/frontend/app/composables/partials/use-actions-factory.ts +++ b/frontend/app/composables/partials/use-actions-factory.ts @@ -26,6 +26,7 @@ export function useReadOnlyActions( api: BaseCRUDAPIReadOnly, allRef: Ref | null, loading: Ref, + initialized: Ref, defaultQueryParams: Record = {}, ): ReadOnlyStoreActions { function getAll(page = 1, perPage = -1, params = {} as Record) { @@ -69,6 +70,7 @@ export function useReadOnlyActions( allRef.value = data.items; } + initialized.value = true; loading.value = false; } @@ -89,6 +91,7 @@ export function useStoreActions( api: BaseCRUDAPI, allRef: Ref | null, loading: Ref, + initialized: Ref, defaultQueryParams: Record = {}, ): StoreActions { function getAll(page = 1, perPage = -1, params = {} as Record) { @@ -132,6 +135,7 @@ export function useStoreActions( allRef.value = data.items; } + initialized.value = true; loading.value = false; } diff --git a/frontend/app/composables/partials/use-store-factory.ts b/frontend/app/composables/partials/use-store-factory.ts index 0a510b25d..faf7d34b5 100644 --- a/frontend/app/composables/partials/use-store-factory.ts +++ b/frontend/app/composables/partials/use-store-factory.ts @@ -16,10 +16,11 @@ export const useReadOnlyStore = function ( storeKey: string, store: Ref, loading: Ref, + initialized: Ref, api: BaseCRUDAPIReadOnly, params = {} as Record, ) { - const storeActions = useReadOnlyActions(`${storeKey}-store-readonly`, api, store, loading); + const storeActions = useReadOnlyActions(`${storeKey}-store-readonly`, api, store, loading, initialized); const actions = { ...storeActions, async refresh() { @@ -27,11 +28,12 @@ export const useReadOnlyStore = function ( }, flushStore() { store.value = []; + initialized.value = false; }, }; // initial hydration - if (!loading.value && !store.value.length) { + if (!loading.value && !initialized.value) { actions.refresh(); } @@ -42,10 +44,11 @@ export const useStore = function ( storeKey: string, store: Ref, loading: Ref, + initialized: Ref, api: BaseCRUDAPI, params = {} as Record, ) { - const storeActions = useStoreActions(`${storeKey}-store`, api, store, loading); + const storeActions = useStoreActions(`${storeKey}-store`, api, store, loading, initialized); const actions = { ...storeActions, async refresh() { @@ -53,11 +56,12 @@ export const useStore = function ( }, flushStore() { store.value = []; + initialized.value = false; }, }; // initial hydration - if (!loading.value && !store.value.length) { + if (!loading.value && !initialized.value) { actions.refresh(); } diff --git a/frontend/app/composables/store/use-category-store.ts b/frontend/app/composables/store/use-category-store.ts index 86121ce68..77d616c2b 100644 --- a/frontend/app/composables/store/use-category-store.ts +++ b/frontend/app/composables/store/use-category-store.ts @@ -5,12 +5,16 @@ import { usePublicExploreApi, useUserApi } from "~/composables/api"; const store: Ref = ref([]); const loading = ref(false); +const initialized = ref(false); const publicLoading = ref(false); +const publicInitialized = ref(false); export function resetCategoryStore() { store.value = []; loading.value = false; + initialized.value = false; publicLoading.value = false; + publicInitialized.value = false; } export const useCategoryData = function () { @@ -23,10 +27,10 @@ export const useCategoryData = function () { export const useCategoryStore = function (i18n?: Composer) { const api = useUserApi(i18n); - return useStore("category", store, loading, api.categories); + return useStore("category", store, loading, initialized, api.categories); }; export const usePublicCategoryStore = function (groupSlug: string, i18n?: Composer) { const api = usePublicExploreApi(groupSlug, i18n).explore; - return useReadOnlyStore("category", store, publicLoading, api.categories); + return useReadOnlyStore("category", store, publicLoading, publicInitialized, api.categories); }; diff --git a/frontend/app/composables/store/use-cookbook-store.ts b/frontend/app/composables/store/use-cookbook-store.ts index 8e5bc4cc1..86071a690 100644 --- a/frontend/app/composables/store/use-cookbook-store.ts +++ b/frontend/app/composables/store/use-cookbook-store.ts @@ -5,17 +5,21 @@ import { usePublicExploreApi, useUserApi } from "~/composables/api"; const cookbooks: Ref = ref([]); const loading = ref(false); +const initialized = ref(false); const publicLoading = ref(false); +const publicInitialized = ref(false); export function resetCookbookStore() { cookbooks.value = []; loading.value = false; + initialized.value = false; publicLoading.value = false; + publicInitialized.value = false; } export const useCookbookStore = function (i18n?: Composer) { const api = useUserApi(i18n); - const store = useStore("cookbook", cookbooks, loading, api.cookbooks); + const store = useStore("cookbook", cookbooks, loading, initialized, api.cookbooks); const updateAll = async function (updateData: UpdateCookBook[]) { loading.value = true; @@ -31,5 +35,5 @@ export const useCookbookStore = function (i18n?: Composer) { export const usePublicCookbookStore = function (groupSlug: string, i18n?: Composer) { const api = usePublicExploreApi(groupSlug, i18n).explore; - return useReadOnlyStore("cookbook", cookbooks, publicLoading, api.cookbooks); + return useReadOnlyStore("cookbook", cookbooks, publicLoading, publicInitialized, api.cookbooks); }; diff --git a/frontend/app/composables/store/use-food-store.ts b/frontend/app/composables/store/use-food-store.ts index 68a19d252..9400f452b 100644 --- a/frontend/app/composables/store/use-food-store.ts +++ b/frontend/app/composables/store/use-food-store.ts @@ -5,12 +5,16 @@ import { usePublicExploreApi, useUserApi } from "~/composables/api"; const store: Ref = ref([]); const loading = ref(false); +const initialized = ref(false); const publicLoading = ref(false); +const publicInitialized = ref(false); export function resetFoodStore() { store.value = []; loading.value = false; + initialized.value = false; publicLoading.value = false; + publicInitialized.value = false; } export const useFoodData = function () { @@ -24,10 +28,10 @@ export const useFoodData = function () { export const useFoodStore = function (i18n?: Composer) { const api = useUserApi(i18n); - return useStore("food", store, loading, api.foods); + return useStore("food", store, loading, initialized, api.foods); }; export const usePublicFoodStore = function (groupSlug: string, i18n?: Composer) { const api = usePublicExploreApi(groupSlug, i18n).explore; - return useReadOnlyStore("food", store, publicLoading, api.foods); + return useReadOnlyStore("food", store, publicLoading, publicInitialized, api.foods); }; diff --git a/frontend/app/composables/store/use-household-store.ts b/frontend/app/composables/store/use-household-store.ts index 468ebd807..f10db3917 100644 --- a/frontend/app/composables/store/use-household-store.ts +++ b/frontend/app/composables/store/use-household-store.ts @@ -5,20 +5,24 @@ import { usePublicExploreApi, useUserApi } from "~/composables/api"; const store: Ref = ref([]); const loading = ref(false); +const initialized = ref(false); const publicLoading = ref(false); +const publicInitialized = ref(false); export function resetHouseholdStore() { store.value = []; loading.value = false; + initialized.value = false; publicLoading.value = false; + publicInitialized.value = false; } export const useHouseholdStore = function (i18n?: Composer) { const api = useUserApi(i18n); - return useReadOnlyStore("household", store, loading, api.households); + return useReadOnlyStore("household", store, loading, initialized, api.households); }; export const usePublicHouseholdStore = function (groupSlug: string, i18n?: Composer) { const api = usePublicExploreApi(groupSlug, i18n).explore; - return useReadOnlyStore("household-public", store, publicLoading, api.households); + return useReadOnlyStore("household-public", store, publicLoading, publicInitialized, api.households); }; diff --git a/frontend/app/composables/store/use-label-store.ts b/frontend/app/composables/store/use-label-store.ts index 394b17092..a8f11db0f 100644 --- a/frontend/app/composables/store/use-label-store.ts +++ b/frontend/app/composables/store/use-label-store.ts @@ -5,10 +5,12 @@ import { useUserApi } from "~/composables/api"; const store: Ref = ref([]); const loading = ref(false); +const initialized = ref(false); export function resetLabelStore() { store.value = []; loading.value = false; + initialized.value = false; } export const useLabelData = function () { @@ -22,5 +24,5 @@ export const useLabelData = function () { export const useLabelStore = function (i18n?: Composer) { const api = useUserApi(i18n); - return useStore("label", store, loading, api.multiPurposeLabels); + return useStore("label", store, loading, initialized, api.multiPurposeLabels); }; diff --git a/frontend/app/composables/store/use-tag-store.ts b/frontend/app/composables/store/use-tag-store.ts index 9526184a4..9421fcb5e 100644 --- a/frontend/app/composables/store/use-tag-store.ts +++ b/frontend/app/composables/store/use-tag-store.ts @@ -5,12 +5,16 @@ import { usePublicExploreApi, useUserApi } from "~/composables/api"; const store: Ref = ref([]); const loading = ref(false); +const initialized = ref(false); const publicLoading = ref(false); +const publicInitialized = ref(false); export function resetTagStore() { store.value = []; loading.value = false; + initialized.value = false; publicLoading.value = false; + publicInitialized.value = false; } export const useTagData = function () { @@ -23,10 +27,10 @@ export const useTagData = function () { export const useTagStore = function (i18n?: Composer) { const api = useUserApi(i18n); - return useStore("tag", store, loading, api.tags); + return useStore("tag", store, loading, initialized, api.tags); }; export const usePublicTagStore = function (groupSlug: string, i18n?: Composer) { const api = usePublicExploreApi(groupSlug, i18n).explore; - return useReadOnlyStore("tag", store, publicLoading, api.tags); + return useReadOnlyStore("tag", store, publicLoading, publicInitialized, api.tags); }; diff --git a/frontend/app/composables/store/use-tool-store.ts b/frontend/app/composables/store/use-tool-store.ts index 2cd998e65..969fa4201 100644 --- a/frontend/app/composables/store/use-tool-store.ts +++ b/frontend/app/composables/store/use-tool-store.ts @@ -9,12 +9,16 @@ interface RecipeToolWithOnHand extends RecipeTool { const store: Ref = ref([]); const loading = ref(false); +const initialized = ref(false); const publicLoading = ref(false); +const publicInitialized = ref(false); export function resetToolStore() { store.value = []; loading.value = false; + initialized.value = false; publicLoading.value = false; + publicInitialized.value = false; } export const useToolData = function () { @@ -29,10 +33,10 @@ export const useToolData = function () { export const useToolStore = function (i18n?: Composer) { const api = useUserApi(i18n); - return useStore("tool", store, loading, api.tools); + return useStore("tool", store, loading, initialized, api.tools); }; export const usePublicToolStore = function (groupSlug: string, i18n?: Composer) { const api = usePublicExploreApi(groupSlug, i18n).explore; - return useReadOnlyStore("tool", store, publicLoading, api.tools); + return useReadOnlyStore("tool", store, publicLoading, publicInitialized, api.tools); }; diff --git a/frontend/app/composables/store/use-unit-store.ts b/frontend/app/composables/store/use-unit-store.ts index b66e2e06f..bbeb8d08d 100644 --- a/frontend/app/composables/store/use-unit-store.ts +++ b/frontend/app/composables/store/use-unit-store.ts @@ -5,10 +5,12 @@ import { useUserApi } from "~/composables/api"; const store: Ref = ref([]); const loading = ref(false); +const initialized = ref(false); export function resetUnitStore() { store.value = []; loading.value = false; + initialized.value = false; } export const useUnitData = function () { @@ -23,5 +25,5 @@ export const useUnitData = function () { export const useUnitStore = function (i18n?: Composer) { const api = useUserApi(i18n); - return useStore("unit", store, loading, api.units); + return useStore("unit", store, loading, initialized, api.units); }; diff --git a/frontend/app/composables/store/use-user-store.ts b/frontend/app/composables/store/use-user-store.ts index d1440c672..bea221bfb 100644 --- a/frontend/app/composables/store/use-user-store.ts +++ b/frontend/app/composables/store/use-user-store.ts @@ -6,10 +6,12 @@ import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; const store: Ref = ref([]); const loading = ref(false); +const initialized = ref(false); export function resetUserStore() { store.value = []; loading.value = false; + initialized.value = false; } class GroupUserAPIReadOnly extends BaseCRUDAPIReadOnly { @@ -21,5 +23,5 @@ export const useUserStore = function (i18n?: Composer) { const requests = useRequests(i18n); const api = new GroupUserAPIReadOnly(requests); - return useReadOnlyStore("user", store, loading, api, { orderBy: "full_name" }); + return useReadOnlyStore("user", store, loading, initialized, api, { orderBy: "full_name" }); };