fix: 回应 Codex 审查的 4 条意见(2×P1 + 2×P2)

- P1 state.ts reduceFor:恢复共享 uid 计数器到调用前水平(目标 session 的
  max 可能低于激活 session 已用 id,否则下次 mint 撞 id 损坏布局);
  回归测试实证:无修复时 split 产生重复 pane id
- P1 service.ts 生命周期分类:dedupe 聚焦判定改为基于输入态的存在性
  (dedupeKey 匹配或 id 匹配都算聚焦)——按 path 去重 + 新 id 打开时
  不再对从未插入的幽灵 tab 触发 onOpen(资源泄漏源)
- P2 SideCardSection:乐观 prefs ref(optimisticRef)驱动嵌套 map 合并
  (tabsEnabled/viewersEnabled/pluginSettings),同 tick 多次写入不再
  互相丢弃;mergePluginSetting 抽纯函数 + 单测
- P2 state.ts schedulePersist:每 session 独立防抖定时器(persistTimers
  Map),定向打开不再取消其他 session 的待写;单测验证双写都落盘
This commit is contained in:
Menghuan1918
2026-08-14 21:13:53 +08:00
parent ced3a1eade
commit 319d34ed09
6 changed files with 190 additions and 19 deletions
+48
View File
@@ -849,3 +849,51 @@ describe('tab meta (v0.12.0)', () => {
expect(tabs[0]?.meta).toBeUndefined()
})
})
describe('lifecycle classification vs dedupe (codex review fixes)', () => {
it('a key-dedupe focus with a NEW id fires onActivate with the REAL tab (no phantom onOpen)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const events: Array<{ kind: string; tabId?: string }> = []
service.registerTab({
id: 'doc',
title: 'Doc',
// Dedupe by PATH like the editor builtin — the focused tab's id
// differs from the newly requested id.
dedupeKey: (tab) => tab.path ?? '',
onOpen: (tab) => { events.push({ kind: 'open', tabId: tab.id }) },
onActivate: (tab) => { events.push({ kind: 'activate', tabId: tab.id }) },
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'doc', title: 'Doc', id: 'doc:1', path: '/a.md' })
expect(events).toEqual([{ kind: 'open', tabId: 'doc:1' }])
// Same path, NEW id: the existing tab is focused — onActivate must
// carry the EXISTING tab, and onOpen must NOT fire with doc:2.
service.openTab({ type: 'doc', title: 'Doc', id: 'doc:2', path: '/a.md' })
expect(events).toEqual([
{ kind: 'open', tabId: 'doc:1' },
{ kind: 'activate', tabId: 'doc:1' },
])
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs).filter(t => t.type === 'doc')
expect(tabs.map(t => t.id)).toEqual(['doc:1'])
})
it('an id safety-net focus (same id) fires onActivate, not onOpen', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const events: string[] = []
service.registerTab({
id: 'multi',
title: 'Multi',
onOpen: () => { events.push('open') },
onActivate: () => { events.push('activate') },
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'multi', title: 'Multi', id: 'm:1' })
expect(events).toEqual(['open'])
service.openTab({ type: 'multi', title: 'Multi', id: 'm:1' })
expect(events).toEqual(['open', 'activate'])
})
})
+21 -1
View File
@@ -20,7 +20,7 @@ import { createElement } from 'react'
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
import { FeatureSettingsRows, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
import { FeatureSettingsRows, mergePluginSetting, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
function mount(): { store: SidebarStore; service: BetterSidebarService } {
@@ -216,3 +216,23 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
expect(html).not.toContain('type="checkbox"')
})
})
describe('mergePluginSetting (v0.12.0, codex review fix)', () => {
it('sequential merges are additive — a later write never drops an earlier key', () => {
// Simulates two same-tick updatePluginSetting calls: each merge spreads
// the map it was GIVEN, so building from the latest optimistic map
// preserves both keys (the pre-fix code spread the stale render-time
// prefs twice and the second write dropped the first key).
let map: Record<string, Record<string, unknown>> = {}
map = mergePluginSetting(map, 'my-plugin:db', 'pageSize', 25)
map = mergePluginSetting(map, 'my-plugin:db', 'theme', 'dark')
expect(map['my-plugin:db']).toEqual({ pageSize: 25, theme: 'dark' })
// A second descriptor's blob stays independent.
map = mergePluginSetting(map, 'other:view', 'refresh', true)
expect(map['my-plugin:db']).toEqual({ pageSize: 25, theme: 'dark' })
expect(map['other:view']).toEqual({ refresh: true })
// Overwriting one key keeps the sibling keys.
map = mergePluginSetting(map, 'my-plugin:db', 'pageSize', 50)
expect(map['my-plugin:db']).toEqual({ pageSize: 50, theme: 'dark' })
})
})
+51
View File
@@ -1690,6 +1690,57 @@ describe('store.reduceFor (targeted opens, v0.12.0)', () => {
expect(store.getSnapshot().state?.panelOpen).toBe(false)
expect(store.getSnapshot().state?.splits).toBeDefined()
})
it('reduceFor never lowers the shared uid counter below the active session needs (no pane-id collision)', () => {
const store = createSidebarStore()
// Session 'b' is cached FIRST with a LOW id range (pane:1 / tab:2).
store.setSession('b')
// Session 'a' then loads and its operations raise the shared counter
// well above b's max (default pane, plus fresh pane ids from splits).
store.setSession('a')
store.reduce(s => splitPane(s, 'row'))
const before = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id).sort()
// A targeted reduce into the OLD, low-id session must not lower the
// counter: the next split in the ACTIVE session would otherwise mint
// an id that already exists (mapLeaf visits both leaves → corruption).
store.reduceFor('b', (state) => state)
store.reduce(s => splitPane(s, 'row'))
const after = allLeaves(store.getSnapshot().state!.splits).map(leaf => leaf.id)
expect(new Set(after).size).toBe(after.length)
// The active session's pre-existing pane ids all survived untouched.
for (const id of before) expect(after).toContain(id)
})
it('persists each session independently (per-session debounce timers)', () => {
const g = globalThis as Record<string, unknown>
let seq = 0
const timers = new Map<number, () => void>()
const writes: string[] = []
g.window = {
clearTimeout: (id: number) => { timers.delete(id) },
setTimeout: (fn: () => void) => { const id = ++seq; timers.set(id, fn); return id },
innerWidth: 1024,
innerHeight: 800,
}
g.localStorage = {
getItem: () => null,
setItem: (key: string) => { writes.push(key) },
}
try {
const store = createSidebarStore()
store.setSession('a')
store.reduce(s => ({ ...s, expanded: ['/a'] })) // schedules persist(a)
store.reduceFor('b', s => ({ ...s, expanded: ['/b'] })) // schedules persist(b)
// A shared timer would have cancelled persist(a) — with per-session
// timers BOTH writes are pending and both land when they fire.
expect(timers.size).toBe(2)
for (const [, fn] of [...timers]) fn()
expect(writes).toEqual(['dsh-sidebar:v1:a', 'dsh-sidebar:v1:b'])
} finally {
delete g.window
delete g.localStorage
}
})
})
describe('tab meta persistence (v0.12.0)', () => {