切换 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 更新存在时序间隙
setSelectedName 和 useEffect 中的 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 个以上条目,则:
// 第 1342-1343 行
const realIdx = (selectedProvider.apiKeyEntries ?? []).indexOf(entry);
const isSelected = selectedEntryIndices.has(realIdx);isSelected 判断会意外命中新 Provider 中下标相同的行,导致"残留"。
问题 3:handleToggleAllVisible 叠加旧状态
// 第 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 回调中,确保两个状态更新在同一个同步批处理中完成:
// 第 1096 行,改为:
onClick={() => {
setSelectedName(p.name);
clearSelection(); // 同步清空,不会有时序间隙
}}同时移除原有的 useEffect:
// 删除第 360-362 行
// useEffect(() => {
// clearSelection();
// }, [clearSelection, selectedName]);优点:改动最小,保证 selectedName 和 selectedEntryIndices 在同一帧内更新。
方案二(更健壮):用 Provider 名称隔离选中状态
将 selectedEntryIndices 改为按 Provider 名称隔离的 Map:
const [selectionMap, setSelectionMap] = useState<Record<string, Set<number>>>({});
const selectedEntryIndices = selectionMap[selectedName] ?? new Set();这样切换 Provider 时自动切换到对应选中状态,完全消除跨 Provider 索引碰撞问题。
优点:彻底解决索引碰撞问题,且支持切换回原 Provider 时保留选中状态。
方案三(防御性补丁):在渲染层过滤无效索引
在 filteredEntries 计算时,确保 selectedEntryIndices 中的索引不超过当前 selectedProvider.apiKeyEntries 的长度:
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]);优点:作为额外安全网,防止任何原因导致的越界索引。
推荐组合
方案一 + 方案三:
- 方案一 解决时序问题(主要修复)
- 方案三 作为防御性安全网(兜底保护)
方案二如果后续有需要保留切换前选中状态的需求,可以单独实施。