You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

NextJS14中Shadcn ComboBox+TanStack虚拟列表下拉不显示问题

问题分析与修复方案

问题根源

  1. 虚拟列表未响应初始状态:useVirtualizer未监听filteredOptions和Popover的打开状态,导致下拉框首次打开时,虚拟列表无法正确计算并渲染初始选项。
  2. 滚动容器引用时机错误:parentRef指向的CommandGroup在Popover未打开时不存在,getScrollElement返回null,虚拟列表无法识别滚动区域,无法渲染选项。
  3. 表单值同步错误:选中选项时,使用了旧的field.value而非当前选中值,导致表单状态未正确更新。

修复步骤

1. 让虚拟列表响应状态变化

用useMemo包裹useVirtualizer,依赖filteredOptions.length和open,确保Popover打开或选项列表变化时重新初始化虚拟列表:

const virtualizer = React.useMemo(() => {
  return useVirtualizer({
    count: filteredOptions.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 35,
    overscan: 5,
  });
}, [filteredOptions.length, open]);

2. 修复表单值同步逻辑

在CommandItem的onSelect中,将选中的currentValue同步到表单:

onSelect={(currentValue) => {
  const isDeselect = currentValue === selectedOption;
  setSelectedOption(isDeselect ? '' : currentValue);
  form.setValue('service', isDeselect ? '' : currentValue);
  setOpen(false);
}}

3. 优化搜索状态重置

添加状态管理搜索框值,在Popover关闭时清空搜索内容并重置选项列表:

const [searchValue, setSearchValue] = React.useState('');

// 处理搜索逻辑
const handleSearch = (search: string) => {
  setSearchValue(search);
  setFilteredOptions(
    options.filter((option) =>
      option.value.toLowerCase().includes(search.toLowerCase())
    ),
  );
};

// 关闭Popover时重置搜索状态
React.useEffect(() => {
  if (!open) {
    setSearchValue('');
    setFilteredOptions(options);
  }
}, [open, options]);

// 绑定搜索框值
<CommandInput
  placeholder="Search IT Service..."
  className="h-9"
  value={searchValue}
  onValueChange={handleSearch}
/>

4. 强制虚拟列表重新计算

Popover打开后,触发虚拟列表重新计算,解决滚动容器延迟挂载的问题:

React.useEffect(() => {
  if (open && parentRef.current) {
    virtualizer.recomputeVirtualItems();
  }
}, [open, virtualizer]);

完整修复代码

'use client';

import { zodResolver } from '@hookform/resolvers/zod';
import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form';
import { z } from 'zod';

import { cn } from '@/app/lib/utils';
import { Button } from '@/components/ui/button';
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from '@/components/ui/command';
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from '@/components/ui/popover';
import { toast } from '@/components/ui/use-toast';
import * as React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';

type Option = {
  value: string;
  label: string;
};

const FormSchema = z.object({
  service: z.string({
    required_error: 'Please select an IT Service.',
  }),
});

export function ComboboxForm({ data }: { data: string[] }) {
  const options = data.map((item) => ({
    value: item,
    label: item,
  }));

  const form = useForm<z.infer<typeof FormSchema>>({
    resolver: zodResolver(FormSchema),
  });

  const [open, setOpen] = React.useState<boolean>(false);
  const [filteredOptions, setFilteredOptions] = React.useState<Option[]>(options);
  const [selectedOption, setSelectedOption] = React.useState<string>('');
  const [searchValue, setSearchValue] = React.useState('');
  const parentRef = React.useRef<HTMLDivElement>(null);

  const virtualizer = React.useMemo(() => {
    return useVirtualizer({
      count: filteredOptions.length,
      getScrollElement: () => parentRef.current,
      estimateSize: () => 35,
      overscan: 5,
    });
  }, [filteredOptions.length, open]);

  const virtualOptions = virtualizer.getVirtualItems();

  const handleSearch = (search: string) => {
    setSearchValue(search);
    setFilteredOptions(
      options.filter((option) =>
        option.value.toLowerCase().includes(search.toLowerCase())
      ),
    );
  };

  React.useEffect(() => {
    if (!open) {
      setSearchValue('');
      setFilteredOptions(options);
    }
  }, [open, options]);

  React.useEffect(() => {
    if (open && parentRef.current) {
      virtualizer.recomputeVirtualItems();
    }
  }, [open, virtualizer]);

  function onSubmit(data: z.infer<typeof FormSchema>) {
    toast({
      title: 'You submitted the following values:',
      description: (
        <pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
          <code className="text-white">{JSON.stringify(data, null, 2)}</code>
        </pre>
      ),
    });
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="service"
          render={({ field }) => (
            <FormItem className="flex flex-col">
              <FormLabel>IT Service</FormLabel>
              <Popover open={open} onOpenChange={setOpen}>
                <PopoverTrigger asChild>
                  <FormControl>
                    <Button
                      variant="outline"
                      role="combobox"
                      className={cn(
                        'w-[384px] justify-between',
                        !selectedOption && 'text-muted-foreground',
                      )}
                    >
                      {selectedOption
                        ? options.find((option) => option.value === selectedOption)?.label
                        : 'Select IT Service'}
                      <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
                    </Button>
                  </FormControl>
                </PopoverTrigger>
                <PopoverContent className="w-[384px] p-0">
                  <Command shouldFilter={false}>
                    <CommandInput
                      placeholder="Search IT Service..."
                      className="h-9"
                      value={searchValue}
                      onValueChange={handleSearch}
                    />
                    <CommandEmpty>No IT Service found.</CommandEmpty>
                    <CommandList>
                      <CommandGroup
                        ref={parentRef}
                        style={{
                          height: '208px',
                          width: '100%',
                          overflow: 'auto',
                        }}
                      >
                        <div
                          style={{
                            height: `${virtualizer.getTotalSize()}px`,
                            width: '100%',
                            position: 'relative',
                          }}
                        >
                          {virtualOptions.map((virtualOption) => {
                            const option = filteredOptions[virtualOption.index];
                            return (
                              <CommandItem
                                key={option.value}
                                value={option.value}
                                style={{
                                  position: 'absolute',
                                  top: 0,
                                  left: 0,
                                  width: '100%',
                                  height: `${virtualOption.size}px`,
                                  transform: `translateY(${virtualOption.start}px)`,
                                }}
                                onSelect={(currentValue) => {
                                  const isDeselect = currentValue === selectedOption;
                                  setSelectedOption(isDeselect ? '' : currentValue);
                                  form.setValue('service', isDeselect ? '' : currentValue);
                                  setOpen(false);
                                }}
                                className="py-2"
                              >
                                {option.label}
                                <CheckIcon
                                  className={cn(
                                    'ml-auto h-4 w-4',
                                    option.value === field.value ? 'opacity-100' : 'opacity-0',
                                  )}
                                />
                              </CommandItem>
                            );
                          })}
                        </div>
                      </CommandGroup>
                    </CommandList>
                  </Command>
                </PopoverContent>
              </Popover>
              <FormDescription>
                This is the IT service to be updated.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">Submit</Button>
      </form>
    </Form>
  );
}

内容的提问来源于stack exchange,提问作者moth

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.21 09:42:01