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)
},
}
}