diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js index c235b889..5c95c7aa 100644 --- a/static/js/components/DuplicatesManager.js +++ b/static/js/components/DuplicatesManager.js @@ -72,7 +72,7 @@ export class DuplicatesManager { this.updateSelectedCount(); } - exitDuplicateMode() { + async exitDuplicateMode() { this.inDuplicateMode = false; this.selectedForDeletion.clear(); @@ -95,13 +95,15 @@ export class DuplicatesManager { recipeGrid.innerHTML = ''; } - // Re-enable virtual scrolling - state.virtualScroller.enable(); - - // Apply a layout switch that was deferred while duplicates mode was active + // Re-enable virtual scrolling, or apply a layout switch deferred + // while duplicates mode was active (enabling the old scroller first + // would let its pending rAF render repopulate the grid after the + // new scroller is created, leaving orphaned/overlapping cards). if (state.pendingLayoutRecreate) { state.pendingLayoutRecreate = false; - recreateVirtualScroll('recipes'); + await recreateVirtualScroll('recipes'); + } else if (state.virtualScroller) { + state.virtualScroller.enable(); } } diff --git a/tests/frontend/components/duplicatesManager.test.js b/tests/frontend/components/duplicatesManager.test.js new file mode 100644 index 00000000..b7527691 --- /dev/null +++ b/tests/frontend/components/duplicatesManager.test.js @@ -0,0 +1,87 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const recreateVirtualScrollMock = vi.fn(); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, +})); + +vi.mock('../../../static/js/components/RecipeCard.js', () => ({ + RecipeCard: class {}, +})); + +vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ + recreateVirtualScroll: recreateVirtualScrollMock, +})); + +const { DuplicatesManager } = await import('../../../static/js/components/DuplicatesManager.js'); +const { state, getCurrentPageState, setCurrentPageType } = await import('../../../static/js/state/index.js'); + +function setupDom() { + document.body.innerHTML = ` +
+
stale
+ `; + document.body.classList.add('duplicate-mode'); +} + +describe('DuplicatesManager exitDuplicateMode', () => { + beforeEach(() => { + vi.clearAllMocks(); + setCurrentPageType('recipes'); + setupDom(); + state.pendingLayoutRecreate = false; + state.virtualScroller = { enable: vi.fn(), disable: vi.fn() }; + }); + + afterEach(() => { + state.pendingLayoutRecreate = false; + state.virtualScroller = null; + }); + + it('skips enable() on the old scroller when a layout recreate was deferred', async () => { + state.pendingLayoutRecreate = true; + + const manager = new DuplicatesManager({}); + manager.inDuplicateMode = true; + await manager.exitDuplicateMode(); + + expect(state.virtualScroller.enable).not.toHaveBeenCalled(); + expect(recreateVirtualScrollMock).toHaveBeenCalledWith('recipes'); + expect(state.pendingLayoutRecreate).toBe(false); + }); + + it('re-enables the existing scroller when no layout recreate is pending', async () => { + const manager = new DuplicatesManager({}); + manager.inDuplicateMode = true; + await manager.exitDuplicateMode(); + + expect(state.virtualScroller.enable).toHaveBeenCalledTimes(1); + expect(recreateVirtualScrollMock).not.toHaveBeenCalled(); + }); + + it('tolerates a missing scroller on the plain re-enable path', async () => { + state.virtualScroller = null; + + const manager = new DuplicatesManager({}); + manager.inDuplicateMode = true; + await expect(manager.exitDuplicateMode()).resolves.toBeUndefined(); + + expect(recreateVirtualScrollMock).not.toHaveBeenCalled(); + }); + + it('clears duplicates-mode state and the grid regardless of path', async () => { + state.pendingLayoutRecreate = true; + + const manager = new DuplicatesManager({}); + manager.inDuplicateMode = true; + await manager.exitDuplicateMode(); + + expect(manager.inDuplicateMode).toBe(false); + expect(getCurrentPageState().duplicatesMode).toBe(false); + expect(document.body.classList.contains('duplicate-mode')).toBe(false); + expect(document.getElementById('recipeGrid').innerHTML).toBe(''); + expect(document.getElementById('duplicatesBanner').style.display).toBe('none'); + }); +});