From b633b227790e45f2767fce5bd99ee1573f1c2afd Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 2 Jun 2026 08:15:29 +0800 Subject: [PATCH] fix(recipe): prevent empty grid by removing preserveScroll from refresh triggers Bug: when scrolling down on recipes page, any operation with preserveScroll: true would fetch only page 1 data then restore scroll position to beyond the loaded items, leaving the grid empty. Fix: - Remove preserveScroll: true from all 7 must-refresh trigger paths (filter, search, sort, import, settings reload, sync, rebuild cache, sidebar folder nav) - Replace full list refresh with updateSingleItem() for repair and bulk missing-LoRA download operations - Update tests to match new scroll-free behavior --- static/js/api/recipeApi.js | 4 ++-- .../ContextMenu/RecipeContextMenu.js | 10 ++++++++-- static/js/managers/BatchImportManager.js | 2 +- .../BulkMissingLoraDownloadManager.js | 19 ++++++++++++++++--- static/js/managers/FilterManager.js | 4 ++-- static/js/managers/SearchManager.js | 2 +- static/js/managers/SettingsManager.js | 2 +- static/js/managers/import/DownloadManager.js | 2 +- static/js/recipes.js | 2 +- tests/frontend/api/recipeApi.bulk.test.js | 16 +++++++--------- 10 files changed, 40 insertions(+), 23 deletions(-) diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index d7df465c..9f135c1b 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -301,7 +301,7 @@ export async function syncChanges() { state.loadingManager.showSimpleLoading('Syncing changes...'); // Simply reload the recipes without rebuilding cache - await resetAndReload(false, { preserveScroll: true }); + await resetAndReload(false); showToast('toast.recipes.syncComplete', {}, 'success'); } catch (error) { @@ -329,7 +329,7 @@ export async function refreshRecipes() { } // After successful cache rebuild, reload the recipes - await resetAndReload(false, { preserveScroll: true }); + await resetAndReload(false); showToast('toast.recipes.refreshComplete', {}, 'success'); } catch (error) { diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index fddb8159..bcd42548 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -306,8 +306,14 @@ export class RecipeContextMenu extends BaseContextMenu { if (result.success) { if (result.repaired > 0) { showToast('recipes.contextMenu.repair.success', {}, 'success'); - // Refresh the current card or reload - this.resetAndReload(); + const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`); + if (detailResponse.ok) { + const updatedRecipe = await detailResponse.json(); + const filePath = this.currentCard?.dataset?.filepath; + if (filePath && state.virtualScroller) { + state.virtualScroller.updateSingleItem(filePath, updatedRecipe); + } + } } else { showToast('recipes.contextMenu.repair.skipped', {}, 'info'); } diff --git a/static/js/managers/BatchImportManager.js b/static/js/managers/BatchImportManager.js index 814020e4..8ff070c3 100644 --- a/static/js/managers/BatchImportManager.js +++ b/static/js/managers/BatchImportManager.js @@ -432,7 +432,7 @@ export class BatchImportManager { // Refresh recipes list to show newly imported recipes if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') { - window.recipeManager.loadRecipes({ preserveScroll: true }); + window.recipeManager.loadRecipes(true); } // Show results step diff --git a/static/js/managers/BulkMissingLoraDownloadManager.js b/static/js/managers/BulkMissingLoraDownloadManager.js index fbf9b82e..e73638b2 100644 --- a/static/js/managers/BulkMissingLoraDownloadManager.js +++ b/static/js/managers/BulkMissingLoraDownloadManager.js @@ -309,9 +309,22 @@ export class BulkMissingLoraDownloadManager { }, 'warning'); } - // Refresh the recipes list to update LoRA status - if (window.recipeManager) { - window.recipeManager.loadRecipes({ preserveScroll: true }); + // Update each affected recipe card with fresh data (LoRA inLibrary flags changed) + if (state.virtualScroller) { + const { extractRecipeId } = await import('../api/recipeApi.js'); + for (const recipe of this.pendingRecipes) { + const recipeId = extractRecipeId(recipe.file_path); + if (!recipeId) continue; + try { + const detailRes = await fetch(`/api/lm/recipe/${encodeURIComponent(recipeId)}`); + if (detailRes.ok) { + const updated = await detailRes.json(); + state.virtualScroller.updateSingleItem(recipe.file_path, updated); + } + } catch (e) { + console.warn('Failed to update recipe card after LoRA download:', e); + } + } } } diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index d3de75a6..5f45abcc 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -662,7 +662,7 @@ export class FilterManager { // Call the appropriate manager's load method based on page type if (this.currentPage === 'recipes' && window.recipeManager) { - await window.recipeManager.loadRecipes({ preserveScroll: true }); + await window.recipeManager.loadRecipes(true); } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { // For models page, reset the page and reload await getModelApiClient().loadMoreWithVirtualScroll(true, false); @@ -746,7 +746,7 @@ export class FilterManager { // Reload data using the appropriate method for the current page if (this.currentPage === 'recipes' && window.recipeManager) { - await window.recipeManager.loadRecipes({ preserveScroll: true }); + await window.recipeManager.loadRecipes(true); } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') { await getModelApiClient().loadMoreWithVirtualScroll(true, true); } diff --git a/static/js/managers/SearchManager.js b/static/js/managers/SearchManager.js index 8796878f..b82e1232 100644 --- a/static/js/managers/SearchManager.js +++ b/static/js/managers/SearchManager.js @@ -301,7 +301,7 @@ export class SearchManager { // Call the appropriate manager's load method based on page type if (this.currentPage === 'recipes' && window.recipeManager) { - window.recipeManager.loadRecipes({ preserveScroll: true }); + window.recipeManager.loadRecipes(true); } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { // For models page, reset the page and reload getModelApiClient().loadMoreWithVirtualScroll(true, false); diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 3284129d..5fbc00fc 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -2876,7 +2876,7 @@ export class SettingsManager { await resetAndReload(false); } else if (this.currentPage === 'recipes') { // Reload the recipes without updating folders - await window.recipeManager.loadRecipes({ preserveScroll: true }); + await window.recipeManager.loadRecipes(true); } else if (this.currentPage === 'checkpoints') { // Reload the checkpoints without updating folders await resetAndReload(false); diff --git a/static/js/managers/import/DownloadManager.js b/static/js/managers/import/DownloadManager.js index 18ad6f65..e553fa26 100644 --- a/static/js/managers/import/DownloadManager.js +++ b/static/js/managers/import/DownloadManager.js @@ -122,7 +122,7 @@ export class DownloadManager { modalManager.closeModal('importModal'); // Refresh the recipe - window.recipeManager.loadRecipes({ preserveScroll: true }); + window.recipeManager.loadRecipes(true); } catch (error) { console.error('Error:', error); diff --git a/static/js/recipes.js b/static/js/recipes.js index 715a9b24..54956167 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -19,7 +19,7 @@ class RecipePageControls { } async resetAndReload() { - await refreshVirtualScroll({ preserveScroll: true }); + await refreshVirtualScroll(); } async refreshModels(fullRebuild = false) { diff --git a/tests/frontend/api/recipeApi.bulk.test.js b/tests/frontend/api/recipeApi.bulk.test.js index 1b9ff3b1..588bbb12 100644 --- a/tests/frontend/api/recipeApi.bulk.test.js +++ b/tests/frontend/api/recipeApi.bulk.test.js @@ -177,9 +177,7 @@ describe('RecipeSidebarApiClient bulk operations', () => { ); }); - it('preserves scroll position for recipe reloads when requested', async () => { - const scrollSnapshot = { scrollContainer: { scrollTop: 480 }, scrollTop: 480 }; - captureScrollPositionMock.mockReturnValue(scrollSnapshot); + it('reloads recipes without preserving scroll', async () => { global.fetch.mockResolvedValue({ ok: true, json: async () => ({ @@ -189,18 +187,18 @@ describe('RecipeSidebarApiClient bulk operations', () => { }), }); - await resetAndReload(false, { preserveScroll: true }); + await resetAndReload(false); - expect(captureScrollPositionMock).toHaveBeenCalledTimes(1); + expect(captureScrollPositionMock).not.toHaveBeenCalled(); expect(virtualScrollerMock.refreshWithData).toHaveBeenCalledWith( [{ id: 'recipe-1' }], 1, false ); - expect(restoreScrollPositionMock).toHaveBeenCalledWith(scrollSnapshot); + expect(restoreScrollPositionMock).not.toHaveBeenCalled(); }); - it('uses scroll-preserving reloads for syncChanges', async () => { + it('uses scroll-free reloads for syncChanges', async () => { global.fetch.mockResolvedValue({ ok: true, json: async () => ({ @@ -212,8 +210,8 @@ describe('RecipeSidebarApiClient bulk operations', () => { await syncChanges(); - expect(captureScrollPositionMock).toHaveBeenCalledTimes(1); - expect(restoreScrollPositionMock).toHaveBeenCalledTimes(1); + expect(captureScrollPositionMock).not.toHaveBeenCalled(); + expect(restoreScrollPositionMock).not.toHaveBeenCalled(); expect(loadingManagerMock.restoreProgressBar).toHaveBeenCalledTimes(1); }); });