Give audit history fetches their own seq owner and label tree row state
governance / governance (pull_request) Successful in 15s

Why: TimelineTab's history effect read the shared events genRef without
incrementing it, so two overlapping history requests both passed the
check and a stale response could overwrite a fresher one, violating the
single-async-owner / drop-stale-events rule. Tree row buttons also hid
their expand state from assistive tech and announced the decorative caret.

Evidence: new src/components/RdmAuditWorkspace.test.tsx (7 cases) covers
out-of-order resolve, stale rejection, cleanup invalidation, ref isolation
and aria attributes; reverting to the non-incrementing read fails 3 cases.
npm test 43 files / 503 tests pass; npm run build ok;
node scripts/check-knip.mjs 50 <= 51; test/design-guard.test.ts 7/7.

Impact: history state now owned by historyGenRef via startLatestFetch
(incremented per fetch, checked on resolve, invalidated on cleanup); the
events-list seq is untouched. Expandable tree buttons expose
aria-expanded, non-expandable rows omit it, caret is aria-hidden.

Refs: review findings on RdmAuditWorkspace (TimelineTab history seq,
tree row a11y); AGENTS.md async-state rule; DESIGN.md

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-10-18 07:27:41 +00:00
co-authored by Cursor
parent 0d6b8d7745
commit 1445f9e2af
2 changed files with 104 additions and 7 deletions
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { startLatestFetch, TreeNodeRow } from './RdmAuditWorkspace'
function deferred<T>() {
let resolve!: (v: T) => void
let reject!: (e: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
const flush = () => new Promise(r => setTimeout(r, 0))
describe('startLatestFetch(历史请求单一所有者)', () => {
it('较新的请求已发起时丢弃过期响应', async () => {
const ref = { current: 0 }
const onData = vi.fn()
const onError = vi.fn()
const older = deferred<string>()
const newer = deferred<string>()
startLatestFetch(ref, () => older.promise, onData, onError)
startLatestFetch(ref, () => newer.promise, onData, onError)
newer.resolve('newer')
await flush()
older.resolve('older')
await flush()
expect(onData.mock.calls).toEqual([['newer']])
expect(onError).not.toHaveBeenCalled()
})
it('过期请求的失败不写回错误', async () => {
const ref = { current: 0 }
const onError = vi.fn()
const older = deferred<string>()
startLatestFetch(ref, () => older.promise, vi.fn(), onError)
startLatestFetch(ref, () => new Promise<string>(() => {}), vi.fn(), onError)
older.reject(new Error('stale'))
await flush()
expect(onError).not.toHaveBeenCalled()
})
it('清理(卸载或参数变化)后在途响应被丢弃', async () => {
const ref = { current: 0 }
const onData = vi.fn()
const pending = deferred<string>()
const cleanup = startLatestFetch(ref, () => pending.promise, onData, vi.fn())
cleanup()
pending.resolve('late')
await flush()
expect(onData).not.toHaveBeenCalled()
})
it('只推进自己的 ref,不影响其他 seq 所有者', () => {
const historyRef = { current: 0 }
const eventsRef = { current: 7 }
startLatestFetch(historyRef, () => new Promise<string>(() => {}), vi.fn(), vi.fn())
expect(historyRef.current).toBe(1)
expect(eventsRef.current).toBe(7)
})
})
describe('TreeNodeRow 无障碍属性', () => {
const noop = () => {}
const render = (item: { kind: string; id: string; title: string; hasChildren?: boolean }, children = {}) =>
renderToStaticMarkup(<TreeNodeRow item={item} children={children} onSelect={noop} onLoadMore={noop} onOpenDoc={noop} />)
it('可展开行:折叠时 aria-expanded=false,箭头 aria-hidden', () => {
const html = render({ kind: 'spine', id: 's1', title: '骨架' })
expect(html).toContain('aria-expanded="false"')
expect(html).toMatch(/<span class="rdm-audit-tree-caret" aria-hidden="true">▸<\/span>/)
})
it('可展开行:已加载下级时 aria-expanded=true', () => {
const html = render({ kind: 'spine', id: 's1', title: '骨架' }, { s1: { items: [], hasMore: false, truncated: false } })
expect(html).toContain('aria-expanded="true"')
expect(html).toMatch(/<span class="rdm-audit-tree-caret" aria-hidden="true">▾<\/span>/)
})
it('不可展开行省略 aria-expanded 与箭头', () => {
const html = render({ kind: 'doc', id: 'd1', title: '文档' })
expect(html).not.toContain('aria-expanded')
expect(html).not.toContain('rdm-audit-tree-caret')
})
})
+20 -7
View File
@@ -394,7 +394,7 @@ function TreeList({ pid, kind, onOpenDoc }: { pid: string; kind: 'notes' | 'docs
)
}
function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenDoc }: {
export function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenDoc }: {
item: TreeItem
selectedId?: string
children: Record<string, AuditTreePage>
@@ -409,8 +409,9 @@ function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenD
return (
<div>
<div className="rdm-audit-tree-row">
<button className={`rdm-audit-tree-node${selectedId === item.id ? ' sel' : ''}`} onClick={() => onSelect(item)}>
{expandable && <span className="rdm-audit-tree-caret">{nested ? '▾' : '▸'}</span>}
<button className={`rdm-audit-tree-node${selectedId === item.id ? ' sel' : ''}`} onClick={() => onSelect(item)}
aria-expanded={expandable ? Boolean(nested) : undefined}>
{expandable && <span className="rdm-audit-tree-caret" aria-hidden="true">{nested ? '▾' : '▸'}</span>}
<span className="rdm-audit-tree-kind">{readable.kind}</span>
<span className="rdm-audit-tree-title">{readable.title}</span>
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : item.state === 'candidate' ? 'warn' : 'plain'}>{humanizeState(item.state)}</Chip>}
@@ -527,6 +528,20 @@ function RelationRow({ r, selected }: { r: RelationEntry; selected: { kind: stri
// ---- 操作记录(事件时间线) ----
// 单一所有者:仅最近一次发起的请求可写回;返回的清理函数作废在途请求。
export function startLatestFetch<T>(
genRef: { current: number },
request: () => Promise<T>,
onData: (data: T) => void,
onError: (message: string) => void,
): () => void {
const gen = ++genRef.current
request()
.then(d => { if (gen === genRef.current) onData(d) })
.catch(e => { if (gen === genRef.current) onError(e instanceof Error ? e.message : String(e)) })
return () => { if (gen === genRef.current) genRef.current++ }
}
function TimelineTab({ pid }: { pid: string }) {
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
const [cursor, setCursor] = useState('')
@@ -538,6 +553,7 @@ function TimelineTab({ pid }: { pid: string }) {
const [historySeq, setHistorySeq] = useState(0)
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
const genRef = useRef(0)
const historyGenRef = useRef(0)
const load = useCallback((cur: string, replace: boolean) => {
const gen = ++genRef.current
@@ -560,12 +576,9 @@ function TimelineTab({ pid }: { pid: string }) {
useEffect(() => {
if (!historyMode) { setHistoryData(null); return }
const gen = genRef.current
setHistoryData(null)
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
.then(d => { if (gen === genRef.current) setHistoryData(d) })
.catch(e => { if (gen === genRef.current) setErr(e.message) })
return startLatestFetch(historyGenRef, () => api.rdmCommand<HistoryData>('rdm.inspect.history', params), setHistoryData, setErr)
}, [pid, historyMode, historySeq])
return (