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
This commit is contained in:
Will Miao
2026-06-02 08:15:29 +08:00
parent 1ffa543160
commit b633b22779
10 changed files with 40 additions and 23 deletions

View File

@@ -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) {

View File

@@ -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');
}

View File

@@ -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

View File

@@ -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);
}
}
}
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -19,7 +19,7 @@ class RecipePageControls {
}
async resetAndReload() {
await refreshVirtualScroll({ preserveScroll: true });
await refreshVirtualScroll();
}
async refreshModels(fullRebuild = false) {

View File

@@ -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);
});
});