Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 81 additions & 5 deletions src/renderer/canvas/worktree/WorktreeTerritoryLayer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -25,5 +50,56 @@ it('allocates the backing store once during setup and only resizes changed dimen
act(() => root.render(<WorktreeTerritoryLayer containerWidth={900} containerHeight={600} />))
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<number, FrameRequestCallback>()
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(<WorktreeTerritoryLayer containerWidth={800} containerHeight={600} />))
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()
}
})
18 changes: 4 additions & 14 deletions src/renderer/canvas/worktree/WorktreeTerritoryLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,20 +315,10 @@ const WorktreeTerritoryLayer: React.FC<Props> = ({ 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()
}
Expand Down
60 changes: 60 additions & 0 deletions src/renderer/canvas/worktree/territoryGL.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
})
25 changes: 23 additions & 2 deletions src/renderer/canvas/worktree/territoryGL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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
}

Expand Down Expand Up @@ -449,6 +469,7 @@ export function createTerritoryGL(canvas: HTMLCanvasElement): TerritoryGL | null
gl.deleteBuffer(vbo)
gl.deleteVertexArray(vao)
gl.deleteProgram(program)
releaseContext(gl)
},
}
}