Give audit history fetches their own seq owner and label tree row state
governance / governance (pull_request) Successful in 15s
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user