@@ -1049,17 +1049,31 @@ export const SetCategoryDialog = memo(function SetCategoryDialog({
10491049} : SetCategoryDialogProps ) {
10501050 const [ categoryInput , setCategoryInput ] = useState ( "" )
10511051 const [ searchQuery , setSearchQuery ] = useState ( "" )
1052+ const [ dialogCategories , setDialogCategories ] = useState < Record < string , Category > > ( { } )
1053+ const [ dialogUseSubcategories , setDialogUseSubcategories ] = useState ( useSubcategories )
10521054 const wasOpen = useRef ( false )
10531055 const scrollContainerRef = useRef < HTMLDivElement > ( null )
1056+ const availableCategoryCount = Object . keys ( availableCategories || { } ) . length
1057+ const dialogCategoryCount = Object . keys ( dialogCategories ) . length
10541058
1055- // Initialize category only when dialog transitions from closed to open
1059+ // Freeze the category list while the dialog is open so background table refreshes
1060+ // do not reshuffle the scroll container. If the dialog opened before categories
1061+ // finished loading, hydrate exactly once when the first non-empty list arrives.
10561062 useEffect ( ( ) => {
10571063 if ( open && ! wasOpen . current ) {
10581064 setCategoryInput ( initialCategory )
10591065 setSearchQuery ( "" )
1066+ setDialogCategories ( availableCategories || { } )
1067+ setDialogUseSubcategories ( useSubcategories )
1068+ } else if ( open && dialogCategoryCount === 0 && availableCategoryCount > 0 ) {
1069+ setDialogCategories ( availableCategories || { } )
1070+ setDialogUseSubcategories ( useSubcategories )
1071+ } else if ( ! open && wasOpen . current ) {
1072+ setDialogCategories ( { } )
1073+ setDialogUseSubcategories ( useSubcategories )
10601074 }
10611075 wasOpen . current = open
1062- } , [ open , initialCategory ] )
1076+ } , [ availableCategories , availableCategoryCount , dialogCategoryCount , initialCategory , open , useSubcategories ] )
10631077
10641078 const handleConfirm = useCallback ( ( ) => {
10651079 onConfirm ( categoryInput )
@@ -1074,13 +1088,13 @@ export const SetCategoryDialog = memo(function SetCategoryDialog({
10741088 } , [ onOpenChange ] )
10751089
10761090 // Filter categories based on search, with subcategory support
1077- const categoryList = Object . keys ( availableCategories || { } ) . sort ( )
1091+ const categoryList = useMemo ( ( ) => Object . keys ( dialogCategories ) . sort ( ) , [ dialogCategories ] )
10781092
10791093 const filteredCategories = useMemo ( ( ) => {
10801094 const query = searchQuery . trim ( ) . toLowerCase ( )
10811095
1082- if ( useSubcategories ) {
1083- const tree = buildCategoryTree ( availableCategories || { } , { } )
1096+ if ( dialogUseSubcategories ) {
1097+ const tree = buildCategoryTree ( dialogCategories , { } )
10841098 const shouldIncludeCache = new Map < CategoryNode , boolean > ( )
10851099
10861100 const shouldIncludeNode = ( node : CategoryNode ) : boolean => {
@@ -1133,16 +1147,10 @@ export const SetCategoryDialog = memo(function SetCategoryDialog({
11331147 displayName : name ,
11341148 level : 0 ,
11351149 } ) )
1136- } , [ availableCategories , categoryList , searchQuery , useSubcategories ] )
1150+ } , [ categoryList , dialogCategories , dialogUseSubcategories , searchQuery ] )
11371151
1138- const shouldUseVirtualization = filteredCategories . length > 50
1139-
1140- const virtualizer = useVirtualizer ( {
1141- count : shouldUseVirtualization ? filteredCategories . length : 0 ,
1142- getScrollElement : ( ) => scrollContainerRef . current ,
1143- estimateSize : ( ) => 36 ,
1144- overscan : 5 ,
1145- } )
1152+ const showLoadingCategories = isLoadingCategories && dialogCategoryCount === 0
1153+ const showSearch = ! showLoadingCategories && categoryList . length > 10
11461154
11471155 return (
11481156 < Dialog open = { open } onOpenChange = { onOpenChange } >
@@ -1155,89 +1163,47 @@ export const SetCategoryDialog = memo(function SetCategoryDialog({
11551163 </ DialogHeader >
11561164 < div className = "py-4 space-y-4" >
11571165 { /* Search bar for categories */ }
1158- { ! isLoadingCategories && categoryList . length > 10 && (
1159- < div className = "space-y-2" >
1160- < Label htmlFor = "categorySearch" > Search Categories</ Label >
1161- < Input
1162- id = "categorySearch"
1163- placeholder = "Type to search..."
1164- value = { searchQuery }
1165- onChange = { ( e : ChangeEvent < HTMLInputElement > ) => setSearchQuery ( e . target . value ) }
1166- />
1167- </ div >
1168- ) }
1166+ < div className = { showSearch ? "space-y-2" : "hidden" } aria-hidden = { ! showSearch } >
1167+ { showSearch && (
1168+ < >
1169+ < Label htmlFor = "categorySearch" > Search Categories</ Label >
1170+ < Input
1171+ id = "categorySearch"
1172+ placeholder = "Type to search..."
1173+ value = { searchQuery }
1174+ onChange = { ( e : ChangeEvent < HTMLInputElement > ) => setSearchQuery ( e . target . value ) }
1175+ />
1176+ </ >
1177+ ) }
1178+ </ div >
11691179
11701180 { /* Category list with optional virtualization */ }
11711181 < div className = "space-y-2" >
11721182 < Label > Select Category</ Label >
1173- { isLoadingCategories ? (
1174- < div className = "max-h-64 border rounded-md p-3 flex items-center justify-center" >
1175- < div className = "flex items-center gap-2 text-muted-foreground" >
1176- < Loader2 className = "h-4 w-4 animate-spin" />
1177- < span className = "text-sm" > Loading categories...</ span >
1183+ < div
1184+ ref = { scrollContainerRef }
1185+ className = "max-h-64 border rounded-md overflow-y-auto"
1186+ >
1187+ { showLoadingCategories ? (
1188+ < div className = "p-3 flex items-center justify-center" >
1189+ < div className = "flex items-center gap-2 text-muted-foreground" >
1190+ < Loader2 className = "h-4 w-4 animate-spin" />
1191+ < span className = "text-sm" > Loading categories...</ span >
1192+ </ div >
11781193 </ div >
1179- </ div >
1180- ) : (
1181- < div
1182- ref = { scrollContainerRef }
1183- className = "max-h-64 border rounded-md overflow-y-auto"
1184- >
1185- { /* No category option */ }
1186- < button
1187- type = "button"
1188- onClick = { ( ) => setCategoryInput ( "" ) }
1189- className = { `w-full text-left px-3 py-2 hover:bg-accent transition-colors ${
1190- categoryInput === "" ? "bg-accent" : ""
1191- } `}
1192- >
1193- < span className = "text-sm text-muted-foreground italic" > (No category)</ span >
1194- </ button >
1195-
1196- { shouldUseVirtualization ? (
1197- // Virtualized rendering for large lists
1198- < div
1199- style = { {
1200- height : `${ virtualizer . getTotalSize ( ) } px` ,
1201- width : "100%" ,
1202- position : "relative" ,
1203- } }
1194+ ) : (
1195+ < >
1196+ { /* No category option */ }
1197+ < button
1198+ type = "button"
1199+ onClick = { ( ) => setCategoryInput ( "" ) }
1200+ className = { `w-full text-left px-3 py-2 hover:bg-accent transition-colors ${
1201+ categoryInput === "" ? "bg-accent" : ""
1202+ } `}
12041203 >
1205- { virtualizer . getVirtualItems ( ) . map ( ( virtualRow ) => {
1206- const category = filteredCategories [ virtualRow . index ]
1207- return (
1208- < div
1209- key = { virtualRow . key }
1210- data-index = { virtualRow . index }
1211- ref = { virtualizer . measureElement }
1212- style = { {
1213- position : "absolute" ,
1214- top : 0 ,
1215- left : 0 ,
1216- width : "100%" ,
1217- transform : `translateY(${ virtualRow . start } px)` ,
1218- } }
1219- >
1220- < button
1221- type = "button"
1222- onClick = { ( ) => setCategoryInput ( category . name ) }
1223- className = { `w-full text-left px-3 py-2 hover:bg-accent transition-colors ${
1224- categoryInput === category . name ? "bg-accent" : ""
1225- } `}
1226- title = { category . name }
1227- >
1228- < span
1229- className = "text-sm"
1230- style = { category . level > 0 ? { paddingLeft : category . level * 12 } : undefined }
1231- >
1232- { category . displayName }
1233- </ span >
1234- </ button >
1235- </ div >
1236- )
1237- } ) }
1238- </ div >
1239- ) : (
1240- // Simple rendering for small lists - much faster!
1204+ < span className = "text-sm text-muted-foreground italic" > (No category)</ span >
1205+ </ button >
1206+
12411207 < div >
12421208 { filteredCategories . map ( ( category ) => (
12431209 < button
@@ -1258,15 +1224,15 @@ export const SetCategoryDialog = memo(function SetCategoryDialog({
12581224 </ button >
12591225 ) ) }
12601226 </ div >
1261- ) }
12621227
1263- { filteredCategories . length === 0 && searchQuery && (
1264- < div className = "px-3 py-6 text-center text-sm text-muted-foreground" >
1265- No categories found matching "{ searchQuery } "
1266- </ div >
1267- ) }
1268- </ div >
1269- ) }
1228+ { filteredCategories . length === 0 && searchQuery && (
1229+ < div className = "px-3 py-6 text-center text-sm text-muted-foreground" >
1230+ No categories found matching "{ searchQuery } "
1231+ </ div >
1232+ ) }
1233+ </ >
1234+ ) }
1235+ </ div >
12701236 </ div >
12711237
12721238 { /* Option to enter new category */ }
0 commit comments