Skip to content

切换 Provider 后表格记录残留问题分析与解决方案

问题描述

ServiceProvidersPage 中,点击侧栏切换到另一个 Provider 时,有时会看到表格中的 API Key 行"残留"——即显示为选中状态,或者显示旧 Provider 的残留数据。


问题根源分析

核心机制

切换 Provider 涉及两个关键状态:

状态位置作用
selectedName第 279 行当前选中的 Provider 名称
selectedEntryIndices第 285 行当前选中的 API Key 行索引(Set<number>

切换 Provider 时的流程:

点击侧栏 → setSelectedName(p.name)
           → useEffect 触发 clearSelection()
           → setSelectedEntryIndices(new Set())

竞态条件

问题 1:clearSelection 与 UI 更新存在时序间隙

setSelectedNameuseEffect 中的 clearSelection 不在同一个同步批处理中。在 React 18 的自动批处理(automatic batching)下,setSelectedName 会在同一帧内同步更新,但 useEffect 回调会在 DOM 提交后、下一帧之前 的微任务阶段执行。

这意味着存在一个短暂的时间窗口:

帧 N:  setSelectedName('B')  → selectedName 变为 'B'
       React 重新渲染 UI     → 表格开始渲染 Provider B 的数据
       ⚠️ 但 selectedEntryIndices 仍然是旧的值

帧 N+1: useEffect 执行       → clearSelection() 才清空

在这一帧间隙内,旧的 selectedEntryIndices(如 {0, 1, 2})会被带入新 Provider 的渲染中。

问题 2:索引跨 Provider 碰撞

selectedEntryIndices 存储的是 数组下标(数字),而非唯一标识符。如果旧 Provider 选中了索引 {0, 1, 2},新 Provider 恰好也有 3 个以上条目,则:

tsx
// 第 1342-1343 行
const realIdx = (selectedProvider.apiKeyEntries ?? []).indexOf(entry);
const isSelected = selectedEntryIndices.has(realIdx);

isSelected 判断会意外命中新 Provider 中下标相同的行,导致"残留"。

问题 3:handleToggleAllVisible 叠加旧状态

tsx
// 第 486-498 行
const handleToggleAllVisible = useCallback(
  (checked: boolean) => {
    setSelectedEntryIndices((prev) => {
      const next = new Set(prev);          // ← 保留了旧的 prev
      filteredEntryIndices.forEach((idx) => { ... });
      return next;
    });

如果在 clearSelection 执行前触发了全选操作,旧索引会叠加进新状态。


推荐解决方案

方案一(推荐):在 setSelectedName 同步清空选中状态

clearSelection 的逻辑从 useEffect 移入 onClick 回调中,确保两个状态更新在同一个同步批处理中完成:

tsx
// 第 1096 行,改为:
onClick={() => {
  setSelectedName(p.name);
  clearSelection();           // 同步清空,不会有时序间隙
}}

同时移除原有的 useEffect

tsx
// 删除第 360-362 行
// useEffect(() => {
//   clearSelection();
// }, [clearSelection, selectedName]);

优点:改动最小,保证 selectedNameselectedEntryIndices 在同一帧内更新。


方案二(更健壮):用 Provider 名称隔离选中状态

selectedEntryIndices 改为按 Provider 名称隔离的 Map:

tsx
const [selectionMap, setSelectionMap] = useState<Record<string, Set<number>>>({});
const selectedEntryIndices = selectionMap[selectedName] ?? new Set();

这样切换 Provider 时自动切换到对应选中状态,完全消除跨 Provider 索引碰撞问题。

优点:彻底解决索引碰撞问题,且支持切换回原 Provider 时保留选中状态。


方案三(防御性补丁):在渲染层过滤无效索引

filteredEntries 计算时,确保 selectedEntryIndices 中的索引不超过当前 selectedProvider.apiKeyEntries 的长度:

tsx
useEffect(() => {
  setSelectedEntryIndices((prev) => {
    const maxIdx = (selectedProvider?.apiKeyEntries ?? []).length;
    const next = new Set<number>();
    for (const idx of prev) {
      if (idx < maxIdx) next.add(idx);
    }
    return next.size === prev.size ? prev : next;
  });
}, [selectedProvider]);

优点:作为额外安全网,防止任何原因导致的越界索引。


推荐组合

方案一 + 方案三

  • 方案一 解决时序问题(主要修复)
  • 方案三 作为防御性安全网(兜底保护)

方案二如果后续有需要保留切换前选中状态的需求,可以单独实施。