Files
dsh-context/tests/client/components/contextViewLazyMount.spec.ts
Bowen Liang a694adfb34 perf(context): cut context-tab render, hover and DNA costs (#167)
* perf(context): cut context-tab render, hover and DNA costs

P0 — interaction/caching:
- isolate pointer hovers: chart-local hover stays immediate, cross-card previews
  trail (140ms; 220ms in DNA mode) so sweeps cannot stack full-view renders;
- cache pure projections (assemble, DNA/file derivations) across hover/remount;
- remove forced layout from the flow/timing read paths, gate the hidden sidebar
  copy, memoize picker options and tool tallies.

P1 — windowing/lazy rendering:
- window trend bars, file-activity rows, timing-strip segments (dense gradient)
  and the browser DNA feed; off-screen rows use content-visibility;
- lazy-mount the browser card and the below-fold event/file row behind
  height-matched skeletons, mounted on an idle slice / near the viewport;
- window DNA bands on long total-mode logs, tighten the DNA window, sample
  per-bar gradient stops, and keep DNA+delta eager so its churn axis stays exact;
- force-mount + reveal for stats file-pill jumps into the lazy row.

Rendering-only by construction: axes, tooltips, ordering, DNA hit-testing and
every readout still come from the full model; scrolling only changes which rows
are in the DOM. Measured on a 987-step session: mount 986-1251ms -> ~510ms,
first paint 480-592ms -> ~80ms, zero long tasks; tab DOM 15,929 -> 3,226
(below-fold mounted ~4.7k); hover 30 -> 0 long tasks; DNA toggle 875 -> ~230ms.

Tests: new window/lazy/DNA/cache specs; lint, typecheck and 138-file 100%
per-file coverage green.

* fix(context): second-review follow-ups

- keep DNA bands fresh per header epoch, and force eager DNA+delta on long
  logs so per-key churn axes stay exact;
- correct windowed spacer heights for the container flex gap: the file list
  and the DNA feed now reproduce the full scroll height exactly;
- re-translate memoized picker/tooltip labels when the locale changes;
- re-observe lazy mounts across detach/reattach (a collapsed sidebar copy)
  via a callback ref, keep forced mounts for stats jumps, and clear the
  chart's local hover/DNA state on mouse leave;
- shrink the window for a mount that already has DNA on, and add the async
  subagent count to the flow-measurement deps;
- repair the skeleton background rule and restore the accidentally
  globalized .lc-heat-ctl .lc-card-sub selector.
2026-10-19 07:38:56 +08:00

74 lines
3.3 KiB
TypeScript

import assert from 'node:assert/strict'
import { act, createElement as h } from 'react'
import { afterEach, describe, test, vi } from 'vitest'
import type { UseChatLike } from '../../../src/client/services'
import { TestClientCtx } from '../helpers/harness'
import { click, flush, mount, queryAll, text } from '../helpers/kit'
import { DICT_EN } from '../../../src/client/i18n'
import { makeView, projectionsFor, timeline } from './contextViewHarness'
type ObserverCallback = IntersectionObserverCallback
function stubIntersection(): { callbacks: ObserverCallback[]; restore: () => void } {
const callbacks: ObserverCallback[] = []
class FakeObserver {
constructor(callback: ObserverCallback) { callbacks.push(callback) }
observe = (): void => {}
disconnect = (): void => {}
}
vi.stubGlobal('IntersectionObserver', FakeObserver)
return { callbacks, restore: () => { vi.unstubAllGlobals() } }
}
describe('ContextView below-fold lazy mount', () => {
afterEach(() => { vi.unstubAllGlobals() })
test('keeps two skeletons until the row nears the viewport, then mounts both cards', async () => {
const observer = stubIntersection()
const idle: (() => void)[] = []
vi.stubGlobal('requestIdleCallback', (callback: () => void) => { idle.push(callback); return 1 })
vi.stubGlobal('cancelIdleCallback', () => {})
const View = makeView(new TestClientCtx())
const m = await mount(h(View, {
sessionId: 'sv-lazy',
useProjection: projectionsFor(timeline({
events: [{ seq: 1, time: 1, kind: 'inject', tokens: 1 }],
fileOps: [{ seq: 2, path: '/src/a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }],
})),
useChat: (selector => selector({ legacy: { nodes: [] } })) as UseChatLike,
}))
try {
// The visible browser card and the below-fold event/file row both hold cards-shaped skeletons.
assert.equal(queryAll(m.container, '.lc-deferred-skeleton').length, 3)
assert.equal(queryAll(m.container, '.lc-fa-card').length, 0)
await act(async () => {
for (const callback of observer.callbacks) callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
})
assert.equal(idle.length, 2)
await act(async () => { for (const run of idle) run() })
await flush()
assert.equal(queryAll(m.container, '.lc-deferred-skeleton').length, 0)
assert.equal(queryAll(m.container, '.lc-fa-card').length, 1)
// A stats file-pill jump forces the lazy row: the card exists and the jump can land on it.
await m.unmount()
const second = await mount(h(View, {
sessionId: 'sv-lazy-jump',
useProjection: projectionsFor(timeline({
fileOps: [{ seq: 2, path: '/src/a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }],
})),
useChat: (selector => selector({ legacy: { nodes: [] } })) as UseChatLike,
}))
const pill = queryAll(second.container, '.lc-flow-pill').find(el => text(el).startsWith(DICT_EN['files.kind.read']))
assert.ok(pill !== undefined)
await click(pill)
await flush()
assert.equal(queryAll(second.container, '.lc-fa-card').length, 1, 'the jump mounts the lazy card')
await second.unmount()
} finally {
await m.unmount()
observer.restore()
}
})
})