Skip to content

Conversation

@cwtalent
Copy link

@cwtalent cwtalent commented Feb 3, 2026

问题描述 / Problem Description

当 Select 组件启用搜索功能(ShowSearch="true")且处于搜索状态时,如果父组件调用 StateHasChanged(),会导致 Select 的选中值被错误地修改。

复现步骤 / Steps to Reproduce

  1. 创建一个页面,包含一个启用搜索的 Select 组件
  2. 在页面中添加定时器,每隔几秒调用 StateHasChanged()
  3. 在 Select 中选择值 A1
  4. 打开下拉框,在搜索框中输入 "2",此时下拉列表只显示 A2
  5. 等待定时器触发 StateHasChanged()
  6. Bug 现象:Select 的值自动从 A1 变成了 A2

复现代码示例

@page "/test"

<Select @bind-Value="currentSelect" Items="items" ShowSearch="true"></Select>

@code {
    private string currentSelect = "A1";
    
    private List<SelectedItem> items = new List<SelectedItem>
    {
        new SelectedItem("A1", "A1"),
        new SelectedItem("A2", "A2"),
        new SelectedItem("A3", "A3"),
    };

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
            _ = Task.Run(async () =>
            {
                while (await timer.WaitForNextTickAsync())
                {
                    await InvokeAsync(() => StateHasChanged());
                }
            });
        }
    }
}

根本原因 / Root Cause

Select.razor.csGetItemByRows() 方法中,使用了 Rows 属性来查找当前选中项。而 Rows 属性会根据 SearchText 返回过滤后的数据。

private SelectedItem? GetItemByRows()
{
    var item = GetItemWithEnumValue()
        ?? Rows.Find(i => i.Value == CurrentValueAsString)  // ❌ Rows 是过滤后的列表
        ?? Rows.Find(i => i.Active)
        ?? Rows.FirstOrDefault(i => !i.IsDisabled);
    return item;
}

当用户在搜索框输入内容时:

  • Rows 只包含搜索结果(例如只有 A2)
  • 当前值是 A1
  • 外部 StateHasChanged() 触发组件重新渲染
  • GetItemByRows() 在过滤后的 Rows 中找不到 A1
  • 最终返回 Rows.FirstOrDefault(),即 A2
  • 导致值被错误修改

解决方案 / Solution

修改 GetItemByRows() 方法,使用完整的未过滤列表 GetRowsByItems() 而不是过滤后的 Rows 来查找当前选中项。

private SelectedItem? GetItemByRows()
{
    // 使用完整的未过滤列表来查找当前选中项
    // 避免在搜索状态下被外部 StateHasChanged 影响导致值被错误修改
    var allItems = GetRowsByItems();
    
    var item = GetItemWithEnumValue()
        ?? allItems.Find(i => i.Value == CurrentValueAsString)  // ✅ 在完整列表中查找
        ?? allItems.Find(i => i.Active)
        ?? allItems.FirstOrDefault(i => !i.IsDisabled);
    return item;
}

性能影响 / Performance Impact

  • 查找操作从过滤列表改为完整列表
  • 对于常见的下拉框数据量(10-1000 项),性能差异可忽略(微秒级)
  • List.Find() 的时间复杂度为 O(n),即使 1000 项也仅需微秒级时间
  • 修复了严重的逻辑 bug,这点微小的性能代价是完全值得的

测试验证 / Testing

修复后,按照上述"复现步骤"进行测试:

  • ✅ 用户选择 A1
  • ✅ 搜索 "2",下拉框显示 A2
  • ✅ 等待定时器触发 StateHasChanged()
  • ✅ Select 的值仍然保持为 A1,不会被错误修改

影响范围 / Impact

  • 仅影响 GetItemByRows() 方法的实现
  • 不影响 API 和公共接口
  • 向后兼容,不会破坏现有功能
  • 修复了在特定场景下的 bug

Summary by Sourcery

Bug Fixes:

  • Prevent the Select component value from changing unexpectedly when ShowSearch is enabled and external StateHasChanged calls occur while searching.

@bb-auto
Copy link

bb-auto bot commented Feb 3, 2026

Thanks for your PR, @cwtalent. Someone from the team will get assigned to your PR shortly and we'll get it reviewed.

@bb-auto bb-auto bot requested a review from ArgoZhang February 3, 2026 02:15
@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 3, 2026

Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Adjusts Select component item resolution to use the full, unfiltered item list instead of the search-filtered list so external StateHasChanged calls no longer corrupt the selected value while searching.

Sequence diagram for Select search with external StateHasChanged after fix

sequenceDiagram
    actor User
    participant ParentComponent
    participant BlazorRenderer
    participant Select
    participant GetItemByRows

    User->>Select: Open dropdown and select A1
    Select->>Select: Set CurrentValueAsString = A1

    User->>Select: Type search text 2
    Select->>Select: Update SearchText
    Select->>Select: Filter Items into Rows (A2 only)

    ParentComponent->>ParentComponent: Timer tick
    ParentComponent->>ParentComponent: StateHasChanged
    ParentComponent->>BlazorRenderer: Request rerender
    BlazorRenderer->>Select: Rebuild component tree

    Select->>GetItemByRows: Resolve selected item
    GetItemByRows->>Select: GetRowsByItems
    Select-->>GetItemByRows: allItems (A1, A2, A3)
    GetItemByRows->>GetItemByRows: Find item where Value == CurrentValueAsString in allItems
    GetItemByRows-->>Select: SelectedItem A1
    Select-->>BlazorRenderer: Render with value A1
    BlazorRenderer-->>User: UI shows A1 selected while search text is 2
Loading

Updated class diagram for Select GetItemByRows logic

classDiagram
    class Select {
      List~SelectedItem~ Rows
      string CurrentValueAsString
      List~SelectedItem~ GetRowsByItems()
      SelectedItem GetItemByRows()
    }

    class SelectedItem {
      string Value
      bool Active
      bool IsDisabled
    }

    Select --> SelectedItem : uses

    class GetItemByRowsLogic {
      List~SelectedItem~ allItems
      +SelectedItem ResolveSelectedItem(string currentValueAsString)
    }

    Select ..> GetItemByRowsLogic : delegates selection resolution

    %% Implementation intent of GetItemByRows after fix
    GetItemByRowsLogic : allItems = GetRowsByItems()
    GetItemByRowsLogic : ResolveSelectedItem
    GetItemByRowsLogic : 1. Try GetItemWithEnumValue
    GetItemByRowsLogic : 2. Find in allItems by Value == CurrentValueAsString
    GetItemByRowsLogic : 3. Else first Active in allItems
    GetItemByRowsLogic : 4. Else first not IsDisabled in allItems
Loading

File-Level Changes

Change Details Files
Resolve the currently selected item from the full underlying item list instead of the search-filtered rows to prevent value changes during search when the component re-renders.
  • Introduce a local allItems list obtained via GetRowsByItems() inside GetItemByRows()
  • Update lookup logic to use allItems.Find for matching CurrentValueAsString, active item, or first non-disabled item
  • Retain GetItemWithEnumValue() precedence while decoupling selection resolution from the search-filtered Rows collection
  • Add inline comments documenting the bug scenario and the rationale for using the full list
src/BootstrapBlazor/Components/Select/Select.razor.cs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • GetRowsByItems() is now called on every GetItemByRows() invocation; if it does any non-trivial work, consider caching its result for the duration of a render or reusing an existing backing field to avoid repeated recomputation.
  • Since GetItemByRows() now ignores the filtered Rows collection entirely, double-check whether any callers relied on the previous behavior of preferring an active item within the current filtered view, and if so consider separating 'current value resolution' from 'filtered active item fallback' to preserve that nuance.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- GetRowsByItems() is now called on every GetItemByRows() invocation; if it does any non-trivial work, consider caching its result for the duration of a render or reusing an existing backing field to avoid repeated recomputation.
- Since GetItemByRows() now ignores the filtered Rows collection entirely, double-check whether any callers relied on the previous behavior of preferring an active item within the current filtered view, and if so consider separating 'current value resolution' from 'filtered active item fallback' to preserve that nuance.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@cwtalent
Copy link
Author

cwtalent commented Feb 3, 2026

@microsoft-github-policy-service agree

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants