diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index 1a676d43..9e834f29 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -1,5 +1,6 @@ import { state, getCurrentPageState } from '../state/index.js'; import { VirtualScroller } from './VirtualScroller.js'; +import { MasonryScroller } from './MasonryScroller.js'; import { createModelCard, setupModelCardEventDelegation } from '../components/shared/ModelCard.js'; import { getModelApiClient } from '../api/modelApiFactory.js'; import { showToast } from './uiHelpers.js'; @@ -141,8 +142,14 @@ async function initializeVirtualScroll(pageType) { throw new Error(`Required components not available for ${pageType} page`); } + // Masonry applies to the recipes page only; the read pattern mirrors + // VirtualScroller.js (display_density). + const useMasonry = pageType === 'recipes' + && (state.global.settings?.recipes_layout ?? 'grid') === 'masonry'; + const ScrollerClass = useMasonry ? MasonryScroller : VirtualScroller; + // Initialize virtual scroller with renamed container elements - state.virtualScroller = new VirtualScroller({ + state.virtualScroller = new ScrollerClass({ gridElement: grid, containerElement: gridContainer, scrollContainer: scrollContainer, @@ -250,3 +257,19 @@ export async function refreshVirtualScroll(options = {}) { } } } + +// Rebuild the virtual scroller from scratch (used for layout switching). +// refreshVirtualScroll only resets the existing instance, so it cannot swap +// the scroller class. Keyboard navigation must be cleaned up first: +// setupKeyboardNavigation appends a new document keydown listener on every +// initializeVirtualScroll call and never removes the previous one. +export async function recreateVirtualScroll(pageType) { + cleanupKeyboardNavigation(); + + if (state.virtualScroller) { + state.virtualScroller.dispose(); + state.virtualScroller = null; + } + + await initializeVirtualScroll(pageType); +} diff --git a/tests/frontend/api/recipeApi.bulk.test.js b/tests/frontend/api/recipeApi.bulk.test.js index f2ad4d3d..780fa1fa 100644 --- a/tests/frontend/api/recipeApi.bulk.test.js +++ b/tests/frontend/api/recipeApi.bulk.test.js @@ -38,6 +38,7 @@ vi.mock('../../../static/js/state/index.js', () => { vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ captureScrollPosition: captureScrollPositionMock, restoreScrollPosition: restoreScrollPositionMock, + recreateVirtualScroll: vi.fn(), })); import { diff --git a/tests/frontend/core/appCore.test.js b/tests/frontend/core/appCore.test.js index 445a9714..71cdae9e 100644 --- a/tests/frontend/core/appCore.test.js +++ b/tests/frontend/core/appCore.test.js @@ -92,6 +92,7 @@ vi.mock('../../../static/js/utils/eventManagementInit.js', () => ({ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ initializeInfiniteScroll: vi.fn(), + recreateVirtualScroll: vi.fn(), })); vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({ diff --git a/tests/frontend/pages/recipesPage.test.js b/tests/frontend/pages/recipesPage.test.js index 21a61497..ff5b9f6b 100644 --- a/tests/frontend/pages/recipesPage.test.js +++ b/tests/frontend/pages/recipesPage.test.js @@ -69,6 +69,7 @@ vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({ refreshVirtualScroll: refreshVirtualScrollMock, + recreateVirtualScroll: vi.fn(), })); vi.mock('../../../static/js/api/recipeApi.js', () => ({ diff --git a/tests/frontend/utils/infiniteScroll.test.js b/tests/frontend/utils/infiniteScroll.test.js new file mode 100644 index 00000000..831fc873 --- /dev/null +++ b/tests/frontend/utils/infiniteScroll.test.js @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { + VirtualScrollerMock, + MasonryScrollerMock, + RecipeCardMock, + fetchRecipesPageMock, + getModelApiClientMock, +} = vi.hoisted(() => { + const makeScrollerClass = () => vi.fn(function (options) { + this.options = options; + this.initialize = vi.fn(async () => {}); + this.dispose = vi.fn(); + this.reset = vi.fn(); + this.handlePageUpDown = vi.fn(); + }); + + return { + VirtualScrollerMock: makeScrollerClass(), + MasonryScrollerMock: makeScrollerClass(), + RecipeCardMock: vi.fn(function () { + this.element = document.createElement('div'); + }), + fetchRecipesPageMock: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })), + getModelApiClientMock: vi.fn(() => ({ + fetchModelsPage: vi.fn(async () => ({ items: [], totalItems: 0, hasMore: false })), + })), + }; +}); + +vi.mock('../../../static/js/utils/VirtualScroller.js', () => ({ + VirtualScroller: VirtualScrollerMock, +})); + +vi.mock('../../../static/js/utils/MasonryScroller.js', () => ({ + MasonryScroller: MasonryScrollerMock, +})); + +vi.mock('../../../static/js/components/RecipeCard.js', () => ({ + RecipeCard: RecipeCardMock, +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + fetchRecipesPage: fetchRecipesPageMock, +})); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + getModelApiClient: getModelApiClientMock, +})); + +vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({ + createModelCard: vi.fn(() => document.createElement('div')), + setupModelCardEventDelegation: vi.fn(), +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: vi.fn(), +})); + +import { + initializeInfiniteScroll, + recreateVirtualScroll, +} from '../../../static/js/utils/infiniteScroll.js'; +import { state } from '../../../static/js/state/index.js'; + +function setupPageDom() { + const pageContent = document.createElement('div'); + pageContent.className = 'page-content'; + const container = document.createElement('div'); + container.className = 'container'; + pageContent.appendChild(container); + document.body.appendChild(pageContent); + + const grid = document.createElement('div'); + vi.spyOn(document, 'getElementById').mockImplementation((id) => + id === 'recipeGrid' || id === 'modelGrid' ? grid : null); + + return { grid }; +} + +describe('infiniteScroll scroller class branching', () => { + let originalSettings; + + beforeEach(() => { + originalSettings = state.global.settings; + state.global.settings = { ...originalSettings }; + if (state.pages.recipes) { + state.pages.recipes.duplicatesMode = false; + } + state.virtualScroller = null; + state.keyboardNavHandler = null; + setupPageDom(); + }); + + afterEach(() => { + state.global.settings = originalSettings; + state.virtualScroller = null; + state.keyboardNavHandler = null; + document.body.innerHTML = ''; + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it('constructs MasonryScroller for the recipes page when recipes_layout is masonry', async () => { + state.global.settings.recipes_layout = 'masonry'; + + await initializeInfiniteScroll('recipes'); + + expect(MasonryScrollerMock).toHaveBeenCalledTimes(1); + expect(VirtualScrollerMock).not.toHaveBeenCalled(); + expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock); + }); + + it('constructs VirtualScroller for the recipes page when recipes_layout is grid', async () => { + state.global.settings.recipes_layout = 'grid'; + + await initializeInfiniteScroll('recipes'); + + expect(VirtualScrollerMock).toHaveBeenCalledTimes(1); + expect(MasonryScrollerMock).not.toHaveBeenCalled(); + expect(state.virtualScroller).toBeInstanceOf(VirtualScrollerMock); + }); + + it('falls back to the grid branch when recipes_layout is missing', async () => { + delete state.global.settings.recipes_layout; + + await expect(initializeInfiniteScroll('recipes')).resolves.toBeUndefined(); + + expect(VirtualScrollerMock).toHaveBeenCalledTimes(1); + expect(MasonryScrollerMock).not.toHaveBeenCalled(); + }); + + it('always constructs VirtualScroller for the loras page regardless of recipes_layout', async () => { + state.global.settings.recipes_layout = 'masonry'; + + await initializeInfiniteScroll('loras'); + + expect(VirtualScrollerMock).toHaveBeenCalledTimes(1); + expect(MasonryScrollerMock).not.toHaveBeenCalled(); + }); +}); + +describe('recreateVirtualScroll', () => { + let originalSettings; + + beforeEach(() => { + originalSettings = state.global.settings; + state.global.settings = { ...originalSettings }; + if (state.pages.recipes) { + state.pages.recipes.duplicatesMode = false; + } + state.virtualScroller = null; + state.keyboardNavHandler = null; + setupPageDom(); + }); + + afterEach(() => { + state.global.settings = originalSettings; + state.virtualScroller = null; + state.keyboardNavHandler = null; + document.body.innerHTML = ''; + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it('cleans up keyboard navigation, disposes the old scroller, and rebuilds with the new layout', async () => { + state.global.settings.recipes_layout = 'grid'; + await initializeInfiniteScroll('recipes'); + const oldScroller = state.virtualScroller; + const oldKeyboardHandler = state.keyboardNavHandler; + expect(oldKeyboardHandler).toBeTypeOf('function'); + + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); + state.global.settings.recipes_layout = 'masonry'; + + await recreateVirtualScroll('recipes'); + + // cleanupKeyboardNavigation removed the previous document keydown listener + expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', oldKeyboardHandler); + // Old scroller disposed and replaced by a new masonry instance + expect(oldScroller.dispose).toHaveBeenCalledTimes(1); + expect(MasonryScrollerMock).toHaveBeenCalledTimes(1); + expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock); + expect(state.virtualScroller).not.toBe(oldScroller); + // A fresh keyboard navigation listener was registered for the new instance + expect(state.keyboardNavHandler).toBeTypeOf('function'); + expect(state.keyboardNavHandler).not.toBe(oldKeyboardHandler); + }); + + it('works when there is no existing virtual scroller', async () => { + state.global.settings.recipes_layout = 'masonry'; + + await expect(recreateVirtualScroll('recipes')).resolves.toBeUndefined(); + + expect(MasonryScrollerMock).toHaveBeenCalledTimes(1); + expect(state.virtualScroller).toBeInstanceOf(MasonryScrollerMock); + }); +});