diff --git a/src/renderer/canvas/worktree/WorktreeTerritoryLayer.test.tsx b/src/renderer/canvas/worktree/WorktreeTerritoryLayer.test.tsx index 13d09742..89195243 100644 --- a/src/renderer/canvas/worktree/WorktreeTerritoryLayer.test.tsx +++ b/src/renderer/canvas/worktree/WorktreeTerritoryLayer.test.tsx @@ -3,11 +3,36 @@ import React, { act } from 'react' import { createRoot } from 'react-dom/client' import { expect, it, vi } from 'vitest' ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true -vi.mock('../../stores/CanvasStoreContext', () => ({ useCanvasStoreApi: () => ({ subscribe: () => () => {} }) })) -vi.mock('../../stores/uiStore', () => ({ useUIStore: { subscribe: () => () => {} } })) -vi.mock('../../drag', () => ({ useDragStore: { subscribe: () => () => {} } })) +const { disposeGL, drawGL, canvasListeners, canvasApi } = vi.hoisted(() => { + const listeners = new Set<() => void>() + return { + disposeGL: vi.fn(), + drawGL: vi.fn(), + canvasListeners: listeners, + canvasApi: { + getState: () => ({ zoomLevel: 1, viewportOffset: { x: 0, y: 0 }, nodes: {} }), + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + }, + } +}) +vi.mock('../../stores/CanvasStoreContext', () => ({ useCanvasStoreApi: () => canvasApi })) +vi.mock('../../stores/uiStore', () => ({ useUIStore: { getState: () => ({ focusedWorktreeId: null }), subscribe: () => () => {} } })) +vi.mock('../../drag', () => ({ useDragStore: { getState: () => ({ source: null, pendingDetach: [] }), subscribe: () => () => {} } })) vi.mock('./useWorktreeMembership', () => ({ useWorktreeMembership: () => ({ groups: [] }) })) -vi.mock('./territoryGL', () => ({ createTerritoryGL: () => ({ resize: vi.fn(), dispose: vi.fn() }), buildPrimitives: vi.fn() })) +vi.mock('./territoryGL', () => ({ + createTerritoryGL: () => ({ + resize: vi.fn(), + setView: vi.fn(), + uploadGeometry: vi.fn(), + uploadMask: vi.fn(), + draw: drawGL, + dispose: disposeGL, + }), + buildPrimitives: vi.fn(), +})) import WorktreeTerritoryLayer from './WorktreeTerritoryLayer' it('allocates the backing store once during setup and only resizes changed dimensions', () => { vi.stubGlobal('requestAnimationFrame', () => 1) @@ -25,5 +50,56 @@ it('allocates the backing store once during setup and only resizes changed dimen act(() => root.render()) expect(widths.mock.calls.filter(([value]) => value === 900)).toHaveLength(1) expect(heights.mock.calls.filter(([value]) => value === 600)).toHaveLength(1) - } finally { act(() => root.unmount()); host.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals() } + } finally { + act(() => root.unmount()) + expect(disposeGL).toHaveBeenCalledTimes(1) + host.remove() + vi.restoreAllMocks() + vi.unstubAllGlobals() + } +}) + +it('coalesces repeated canvas changes into one territory frame', () => { + const frames = new Map() + let nextFrame = 0 + const requestFrame = vi.fn((callback: FrameRequestCallback) => { + const id = ++nextFrame + frames.set(id, callback) + return id + }) + vi.stubGlobal('requestAnimationFrame', requestFrame) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + vi.stubGlobal('devicePixelRatio', 1) + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + try { + act(() => root.render()) + const initialFrame = frames.get(1) + expect(initialFrame).toBeDefined() + act(() => initialFrame?.(0)) + requestFrame.mockClear() + drawGL.mockClear() + + act(() => { + for (const listener of canvasListeners) { + listener() + listener() + listener() + } + }) + expect(requestFrame).toHaveBeenCalledTimes(1) + expect(drawGL).not.toHaveBeenCalled() + + const coalescedFrame = frames.get(2) + expect(coalescedFrame).toBeDefined() + act(() => coalescedFrame?.(16)) + expect(drawGL).toHaveBeenCalledTimes(1) + } finally { + act(() => root.unmount()) + host.remove() + canvasListeners.clear() + vi.restoreAllMocks() + vi.unstubAllGlobals() + } }) diff --git a/src/renderer/canvas/worktree/WorktreeTerritoryLayer.tsx b/src/renderer/canvas/worktree/WorktreeTerritoryLayer.tsx index 4d4cb0c6..2ce6281f 100644 --- a/src/renderer/canvas/worktree/WorktreeTerritoryLayer.tsx +++ b/src/renderer/canvas/worktree/WorktreeTerritoryLayer.tsx @@ -315,20 +315,10 @@ const WorktreeTerritoryLayer: React.FC = ({ containerWidth, containerHeig ensureRef.current = ensure const onChange = () => { - // The world transform (Canvas.applyTransform) updates the DOM synchronously - // on every offset change. The GL territory draw is just one full-screen quad, - // so draw it synchronously in the SAME store-notification tick — phase-locked - // to the panels. Scheduling it on a later rAF instead makes the territory lag - // the panels by a frame during fast pan/zoom (very visible when zoomed out, - // where one pan event is a large world delta). Node-drag still uses the rAF - // loop (to follow the ghost), and the CPU fallback stays on rAF (its draw is - // far too heavy to run per pan event). - const dragging = useDragStore.getState().source?.origin.kind === 'canvas-node' - if (backendRef.current === 'gl' && !dragging) { - dirtyRef.current = false - paintGL() - return - } + // Store notifications can arrive faster than Chromium can composite a + // frame. Drawing synchronously here queues redundant full-screen GPU work + // and makes pan input contend with the compositor. Keep view updates + // dirty-gated and coalesce them into the next animation frame instead. dirtyRef.current = true ensure() } diff --git a/src/renderer/canvas/worktree/territoryGL.test.tsx b/src/renderer/canvas/worktree/territoryGL.test.tsx new file mode 100644 index 00000000..55b36f89 --- /dev/null +++ b/src/renderer/canvas/worktree/territoryGL.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +import { expect, it, vi } from 'vitest' +import { createTerritoryGL } from './territoryGL' + +function webglHarness(fragmentPrecision = 23) { + const loseContext = vi.fn() + const gl = { + FRAGMENT_SHADER: 1, VERTEX_SHADER: 2, HIGH_FLOAT: 3, + COMPILE_STATUS: 4, LINK_STATUS: 5, ARRAY_BUFFER: 6, STATIC_DRAW: 7, FLOAT: 8, + TEXTURE0: 9, TEXTURE1: 10, TEXTURE_2D: 11, TEXTURE_MIN_FILTER: 12, + TEXTURE_MAG_FILTER: 13, TEXTURE_WRAP_S: 14, TEXTURE_WRAP_T: 15, + NEAREST: 16, LINEAR: 17, CLAMP_TO_EDGE: 18, RGBA32F: 19, R8: 20, + RED: 21, UNSIGNED_BYTE: 22, UNPACK_ALIGNMENT: 23, DEPTH_TEST: 24, + BLEND: 25, FUNC_ADD: 26, ONE: 27, ONE_MINUS_SRC_ALPHA: 28, + getShaderPrecisionFormat: vi.fn(() => ({ precision: fragmentPrecision })), + createShader: vi.fn(() => ({})), shaderSource: vi.fn(), compileShader: vi.fn(), + getShaderParameter: vi.fn(() => true), getShaderInfoLog: vi.fn(() => ''), + deleteShader: vi.fn(), createProgram: vi.fn(() => ({})), attachShader: vi.fn(), + bindAttribLocation: vi.fn(), linkProgram: vi.fn(), + getProgramParameter: vi.fn(() => true), getProgramInfoLog: vi.fn(() => ''), + deleteProgram: vi.fn(), createVertexArray: vi.fn(() => ({})), + bindVertexArray: vi.fn(), deleteVertexArray: vi.fn(), + createBuffer: vi.fn(() => ({})), bindBuffer: vi.fn(), bufferData: vi.fn(), + deleteBuffer: vi.fn(), enableVertexAttribArray: vi.fn(), vertexAttribPointer: vi.fn(), + getUniformLocation: vi.fn(() => ({})), createTexture: vi.fn(() => ({})), + activeTexture: vi.fn(), bindTexture: vi.fn(), texParameteri: vi.fn(), + texStorage2D: vi.fn(), texImage2D: vi.fn(), deleteTexture: vi.fn(), + useProgram: vi.fn(), uniform1i: vi.fn(), pixelStorei: vi.fn(), disable: vi.fn(), + enable: vi.fn(), blendEquation: vi.fn(), blendFunc: vi.fn(), clearColor: vi.fn(), + getExtension: vi.fn((name: string) => name === 'WEBGL_lose_context' ? { loseContext } : null), + } as unknown as WebGL2RenderingContext + return { gl, loseContext } +} + +it('releases the underlying context exactly once on dispose', () => { + const { gl, loseContext } = webglHarness() + const canvas = document.createElement('canvas') + vi.spyOn(canvas, 'getContext').mockReturnValue(gl) + + const renderer = createTerritoryGL(canvas) + expect(renderer).not.toBeNull() + renderer?.dispose() + renderer?.dispose() + + expect(gl.deleteTexture).toHaveBeenCalledTimes(2) + expect(gl.deleteBuffer).toHaveBeenCalledTimes(1) + expect(gl.deleteVertexArray).toHaveBeenCalledTimes(1) + expect(gl.deleteProgram).toHaveBeenCalledTimes(1) + expect(gl.getExtension).toHaveBeenCalledWith('WEBGL_lose_context') + expect(loseContext).toHaveBeenCalledTimes(1) +}) + +it('releases a context when initialization falls back to the CPU', () => { + const { gl, loseContext } = webglHarness(0) + const canvas = document.createElement('canvas') + vi.spyOn(canvas, 'getContext').mockReturnValue(gl) + + expect(createTerritoryGL(canvas)).toBeNull() + expect(loseContext).toHaveBeenCalledTimes(1) +}) diff --git a/src/renderer/canvas/worktree/territoryGL.ts b/src/renderer/canvas/worktree/territoryGL.ts index ae985d99..ebdd4ed9 100644 --- a/src/renderer/canvas/worktree/territoryGL.ts +++ b/src/renderer/canvas/worktree/territoryGL.ts @@ -246,6 +246,13 @@ function compile(gl: WebGL2RenderingContext, type: number, src: string): WebGLSh return sh } +/** Explicitly surrender the context instead of waiting for Chromium's GC. + * Canvas subtrees are remounted on workspace switches, so deferred context + * collection otherwise lets GPU resources accumulate over a long session. */ +function releaseContext(gl: WebGL2RenderingContext): void { + gl.getExtension('WEBGL_lose_context')?.loseContext() +} + export interface TerritoryGL { resize(deviceW: number, deviceH: number): void setView(zoom: number, offX: number, offY: number, dpr: number): void @@ -280,13 +287,25 @@ export function createTerritoryGL(canvas: HTMLCanvasElement): TerritoryGL | null const hp = gl.getShaderPrecisionFormat(gl.FRAGMENT_SHADER, gl.HIGH_FLOAT) if (!hp || hp.precision === 0) { console.warn('[territoryGL] no fragment highp float; falling back to CPU') + releaseContext(gl) return null } const vs = compile(gl, gl.VERTEX_SHADER, VERT_SRC) const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG_SRC) - if (!vs || !fs) return null - const program = gl.createProgram()! + if (!vs || !fs) { + if (vs) gl.deleteShader(vs) + if (fs) gl.deleteShader(fs) + releaseContext(gl) + return null + } + const program = gl.createProgram() + if (!program) { + gl.deleteShader(vs) + gl.deleteShader(fs) + releaseContext(gl) + return null + } gl.attachShader(program, vs) gl.attachShader(program, fs) gl.bindAttribLocation(program, 0, 'aPos') @@ -296,6 +315,7 @@ export function createTerritoryGL(canvas: HTMLCanvasElement): TerritoryGL | null if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error('[territoryGL] program link failed:', gl.getProgramInfoLog(program)) gl.deleteProgram(program) + releaseContext(gl) return null } @@ -449,6 +469,7 @@ export function createTerritoryGL(canvas: HTMLCanvasElement): TerritoryGL | null gl.deleteBuffer(vbo) gl.deleteVertexArray(vao) gl.deleteProgram(program) + releaseContext(gl) }, } }