From 9eee300b97b01c1f147d36cf282b41715682932d Mon Sep 17 00:00:00 2001 From: C3B2W23 <217007207+C3B2W23@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:15:30 -0700 Subject: [PATCH] fix(filters): keep the filter dialog responsive on very long option lists The vessel name list runs to tens of thousands of entries and every one was mounted as a button when the dialog opened, so the Maritime dialog took several seconds to appear. Because the dialog centres itself in an effect after the first paint, that delay also showed it flashing at the top-left before jumping to the middle. Mount at most 300 rows until the search narrows the list, say how many are hidden, and centre in a layout effect so the first paint is already in place. Co-Authored-By: Claude Fable 5.1 --- .../components/AdvancedFilterModal.test.tsx | 56 +++++++++++++++++++ .../src/components/AdvancedFilterModal.tsx | 25 +++++++-- 2 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 frontend/src/__tests__/components/AdvancedFilterModal.test.tsx diff --git a/frontend/src/__tests__/components/AdvancedFilterModal.test.tsx b/frontend/src/__tests__/components/AdvancedFilterModal.test.tsx new file mode 100644 index 0000000..f85f92f --- /dev/null +++ b/frontend/src/__tests__/components/AdvancedFilterModal.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import AdvancedFilterModal, { MAX_RENDERED_OPTIONS } from '@/components/AdvancedFilterModal'; + +vi.mock('@/lib/motion', () => ({ + motion: { + div: ({ children, ...props }: React.ComponentProps<'div'> & Record) => { + const rest = { ...props }; + for (const key of ['initial', 'animate', 'exit', 'transition']) delete rest[key]; + return
)}>{children}
; + }, + }, + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +const names = Array.from({ length: 1000 }, (_, i) => `VESSEL ${String(i).padStart(4, '0')}`); + +function renderModal() { + return render( + , + ); +} + +describe('AdvancedFilterModal large option lists', () => { + afterEach(() => cleanup()); + + it('mounts only the head of a long list and says how much is hidden', () => { + renderModal(); + const rows = screen.getAllByRole('button').filter((b) => b.textContent?.startsWith('VESSEL ')); + expect(rows).toHaveLength(MAX_RENDERED_OPTIONS); + expect(screen.getByText('1000 AVAILABLE')).toBeTruthy(); + expect(screen.getByText(/SHOWING 300 OF 1,000/)).toBeTruthy(); + }); + + it('renders the whole list once the search narrows it', () => { + renderModal(); + fireEvent.change(screen.getByPlaceholderText(/Search vessel name/i), { + target: { value: 'VESSEL 09' }, + }); + const rows = screen.getAllByRole('button').filter((b) => b.textContent?.startsWith('VESSEL ')); + expect(rows).toHaveLength(100); + expect(screen.queryByText(/SHOWING/)).toBeNull(); + expect(screen.getByText('100 AVAILABLE')).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/AdvancedFilterModal.tsx b/frontend/src/components/AdvancedFilterModal.tsx index ab20265..39f99df 100644 --- a/frontend/src/components/AdvancedFilterModal.tsx +++ b/frontend/src/components/AdvancedFilterModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; +import { useState, useMemo, useRef, useCallback, useEffect, useLayoutEffect } from 'react'; import { motion } from '@/lib/motion'; import { Search, X, Check, GripHorizontal } from 'lucide-react'; @@ -11,6 +11,11 @@ interface FilterField { optionLabels?: Record; } +// Option lists come straight from live data (vessel names run to tens of +// thousands); rendering every row as a button makes the dialog take seconds +// to open, so only the head of the list is mounted until the search narrows it. +export const MAX_RENDERED_OPTIONS = 300; + interface AdvancedFilterModalProps { title: string; icon: React.ReactNode; @@ -55,8 +60,8 @@ export default function AdvancedFilterModal({ const dragStartRef = useRef({ x: 0, y: 0, posX: 0, posY: 0 }); const modalRef = useRef(null); - // Center on mount, clamped so it doesn't overlap the bottom status bar (~48px) - useEffect(() => { + // Center before first paint, clamped so it doesn't overlap the bottom status bar (~48px) + useLayoutEffect(() => { if (modalRef.current) { const rect = modalRef.current.getBoundingClientRect(); const pad = 52; // status bar + small gap @@ -154,6 +159,12 @@ export default function AdvancedFilterModal({ }); }, [activeField, activeTab, searchTerms]); + const visibleOptions = useMemo( + () => filteredOptions.slice(0, MAX_RENDERED_OPTIONS), + [filteredOptions], + ); + const hiddenCount = filteredOptions.length - visibleOptions.length; + // Tailwind color map for dynamic classes const colorMap: Record< string, @@ -355,7 +366,7 @@ export default function AdvancedFilterModal({ ) : (
- {filteredOptions.map((option) => { + {visibleOptions.map((option) => { const isChecked = draft[activeTab]?.has(option); return (
)}