From 0683bc3202dae190ed1c07dbb5dc1a18916c083d Mon Sep 17 00:00:00 2001 From: Yt4aZaveta Date: Thu, 1 Oct 2026 16:27:14 +0300 Subject: [PATCH 1/3] fix(terminal): smooth WebGL renderer transitions --- .../src/features/terminal/TerminalCard.tsx | 94 ++++++++++++++++++- tests/webgl-context-pool.test.mjs | 2 + 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index 65d271de..6c5ae3c5 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -242,6 +242,91 @@ function TerminalCardView({ const searchAddonRef = useRef(null); const webglAddonRef = useRef(null); const fitRef = useRef<(() => void) | null>(null); + const rendererTransitionRef = useRef<{ + snapshot: HTMLElement; + frame: number | null; + render: { dispose(): void } | null; + fallback: number | null; + animation: Animation | null; + } | null>(null); + const restoreRendererTransition = (): void => { + const transition = rendererTransitionRef.current; + if (!transition) return; + rendererTransitionRef.current = null; + if (transition.frame !== null) cancelAnimationFrame(transition.frame); + if (transition.fallback !== null) window.clearTimeout(transition.fallback); + transition.render?.dispose(); + transition.animation?.cancel(); + transition.snapshot.remove(); + }; + const beginRendererTransition = (terminal: Terminal): HTMLElement | null => { + restoreRendererTransition(); + const element = terminal.element; + const screen = element?.querySelector(".xterm-screen"); + if (!element || !screen || !screen.checkVisibility({ visibilityProperty: true })) return null; + const webglCanvas = element.dataset.renderer === "webgl" + ? screen.querySelector("canvas:not([class])") : null; + if (webglCanvas?.getContext("webgl2")?.isContextLost()) return null; + const snapshot = screen.cloneNode(true) as HTMLElement; + // DOM renderer disposal removes its owner class; keep the cloned row styles scoped to the snapshot. + for (const className of element.classList) { + if (className.startsWith("xterm-dom-renderer-owner-") || className === "focus") snapshot.classList.add(className); + } + snapshot.setAttribute("data-renderer-transition", "true"); + snapshot.setAttribute("aria-hidden", "true"); + snapshot.inert = true; + Object.assign(snapshot.style, { + position: "absolute", left: `${screen.offsetLeft}px`, top: `${screen.offsetTop}px`, + pointerEvents: "none", zIndex: "4", overflow: "hidden", + backgroundColor: "var(--terminal-background, #202430)" + }); + // cloneNode copies DOM rows, but canvas pixels must be copied before disposing WebGL. + const canvases = snapshot.querySelectorAll("canvas"); + try { + screen.querySelectorAll("canvas").forEach((canvas, index) => { + const context = canvases[index].getContext("2d"); + if (!context) throw new Error("Renderer snapshot unavailable"); + context.drawImage(canvas, 0, 0); + }); + } catch { + return null; + } + element.append(snapshot); + rendererTransitionRef.current = { snapshot, frame: null, render: null, fallback: null, animation: null }; + return snapshot; + }; + const revealRendererTransition = (terminal: Terminal, snapshot: HTMLElement): void => { + const transition = rendererTransitionRef.current; + if (!transition || transition.snapshot !== snapshot) return; + const fadeAfterPaint = (): void => { + if (rendererTransitionRef.current !== transition) return; + transition.frame = null; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { + restoreRendererTransition(); + return; + } + transition.animation = snapshot.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: 180, easing: "ease-out", fill: "forwards" + }); + transition.animation.onfinish = () => { + if (rendererTransitionRef.current === transition) restoreRendererTransition(); + }; + }; + const ready = (): void => { + if (rendererTransitionRef.current !== transition || transition.frame !== null) return; + transition.render?.dispose(); + transition.render = null; + if (transition.fallback !== null) window.clearTimeout(transition.fallback); + transition.fallback = null; + transition.frame = requestAnimationFrame(() => { + transition.frame = requestAnimationFrame(fadeAfterPaint); + }); + }; + transition.render = terminal.onRender(ready); + // Offscreen or lost contexts may never emit onRender; never leave a stale overlay behind. + transition.fallback = window.setTimeout(ready, 250); + terminal.refresh(0, terminal.rows - 1); + }; const searchInputRef = useRef(null); const searchOpenRef = useRef(false); const [searchOpen, setSearchOpen] = useState(false); @@ -544,6 +629,7 @@ function TerminalCardView({ return () => { // No refit on the way out: the card is going away, its PTY size must not change. fitRef.current = null; + restoreRendererTransition(); unregisterWebgl(); cancelAnimationFrame(frame); detachMouseCoordinateAdapter(); @@ -574,11 +660,13 @@ function TerminalCardView({ const enableWebgl = (terminal: Terminal): boolean => { if (webglAddonRef.current) return true; + const transitionElement = beginRendererTransition(terminal); // WebglAddon takes no transparency argument in 0.19.0: it reads the stored // terminal options, and this terminal is constructed with allowTransparency, // so cell backgrounds stay transparent and the card's palette background // keeps showing through the canvas exactly as it does in the DOM renderer. - const webgl = new WebglAddon(); + // Preserve the last painted frame for the short snapshot when switching back to DOM. + const webgl = new WebglAddon(true); webgl.onContextLoss(() => { // GPU context gone and not restored: drop the renderer, xterm falls back to the DOM renderer with // the buffer intact, and the pool keeps this card off WebGL for a while. @@ -591,17 +679,20 @@ function TerminalCardView({ } catch { // WebGL2 unavailable — stay on the DOM renderer. webgl.dispose(); + restoreRendererTransition(); return false; } webglAddonRef.current = webgl; terminal.element?.setAttribute("data-renderer", "webgl"); fitRef.current?.(); + if (transitionElement) revealRendererTransition(terminal, transitionElement); return true; }; const disableWebgl = (terminal: Terminal): void => { const webgl = webglAddonRef.current; if (!webgl) return; + const transitionElement = fitRef.current ? beginRendererTransition(terminal) : null; webglAddonRef.current = null; // The WebGL canvas is the one xterm-screen child without a layer class (the link layer is a 2D canvas). const canvas = terminal.element?.querySelector(".xterm-screen > canvas:not([class])"); @@ -610,6 +701,7 @@ function TerminalCardView({ // WebGL snaps the cell width down to whole device pixels, so its cells can be narrower than the DOM // renderer's. A grid fitted while on WebGL may then be too wide for DOM: fit again. fitRef.current?.(); + if (transitionElement) revealRendererTransition(terminal, transitionElement); // Disposing the addon drops the canvas but not its context, which counts against Chromium's // per-renderer limit until it is collected. Lose it now so the slot is really free. getContext // returns the canvas's existing context here; it creates nothing. diff --git a/tests/webgl-context-pool.test.mjs b/tests/webgl-context-pool.test.mjs index 73ffc17c..946aa745 100644 --- a/tests/webgl-context-pool.test.mjs +++ b/tests/webgl-context-pool.test.mjs @@ -346,6 +346,8 @@ test("TerminalCard routes its renderer through the pool and frees the context on assert.match(card, /pool\.register\(session\.id,/); assert.match(card, /webglContextPool\(\)\.contextLost\(session\.id\)/); assert.match(card, /WEBGL_lose_context/); + assert.match(card, /data-renderer-transition/); + assert.match(card, /requestAnimationFrame\(/); assert.doesNotMatch(card, /if \(focused && !summaryMode && zoom <= WEBGL_MAX_SCALE\) enableWebgl/); const canvas = await readFile(new URL("../src/renderer/src/features/workspace/WorkspaceCanvas.tsx", import.meta.url), "utf8"); assert.match(canvas, /webglContextPool\(\)\.viewportChanged\(\);\r?\n \}, \[camera\.x, camera\.y, camera\.zoom/); From bf2ba30c520ce275816251ab56159dc4365dad76 Mon Sep 17 00:00:00 2001 From: Yt4aZaveta Date: Thu, 1 Oct 2026 18:40:46 +0300 Subject: [PATCH 2/3] fix(terminal): bound renderer transition cleanup --- .../src/features/terminal/TerminalCard.tsx | 27 +++- tests/terminal-renderer-transition.test.mjs | 131 ++++++++++++++++++ 2 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 tests/terminal-renderer-transition.test.mjs diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index 213cd66f..fe488d0d 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -266,6 +266,7 @@ function TerminalCardView({ render: { dispose(): void } | null; fallback: number | null; animation: Animation | null; + visibilityCleanup: (() => void) | null; } | null>(null); const restoreRendererTransition = (): void => { const transition = rendererTransitionRef.current; @@ -273,6 +274,7 @@ function TerminalCardView({ rendererTransitionRef.current = null; if (transition.frame !== null) cancelAnimationFrame(transition.frame); if (transition.fallback !== null) window.clearTimeout(transition.fallback); + transition.visibilityCleanup?.(); transition.render?.dispose(); transition.animation?.cancel(); transition.snapshot.remove(); @@ -310,7 +312,18 @@ function TerminalCardView({ return null; } element.append(snapshot); - rendererTransitionRef.current = { snapshot, frame: null, render: null, fallback: null, animation: null }; + const onVisibilityChange = (): void => { + if (document.hidden) restoreRendererTransition(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + rendererTransitionRef.current = { + snapshot, + frame: null, + render: null, + fallback: window.setTimeout(restoreRendererTransition, 250), + animation: null, + visibilityCleanup: () => document.removeEventListener("visibilitychange", onVisibilityChange) + }; return snapshot; }; const revealRendererTransition = (terminal: Terminal, snapshot: HTMLElement): void => { @@ -334,15 +347,11 @@ function TerminalCardView({ if (rendererTransitionRef.current !== transition || transition.frame !== null) return; transition.render?.dispose(); transition.render = null; - if (transition.fallback !== null) window.clearTimeout(transition.fallback); - transition.fallback = null; transition.frame = requestAnimationFrame(() => { transition.frame = requestAnimationFrame(fadeAfterPaint); }); }; transition.render = terminal.onRender(ready); - // Offscreen or lost contexts may never emit onRender; never leave a stale overlay behind. - transition.fallback = window.setTimeout(ready, 250); terminal.refresh(0, terminal.rows - 1); }; const lifecycle = surfaceLifecycle({ summary: summaryMode, hidden, focused }); @@ -707,7 +716,9 @@ function TerminalCardView({ // terminal options, and this terminal is constructed with allowTransparency, // so cell backgrounds stay transparent and the card's palette background // keeps showing through the canvas exactly as it does in the DOM renderer. - // Preserve the last painted frame for the short snapshot when switching back to DOM. + // Preserve the last painted frame for the short snapshot when switching back to DOM. A one-shot + // readback after compositing is not reliable with preserveDrawingBuffer disabled; the bounded WebGL + // pool still limits the number of contexts that pay this cost. const webgl = new WebglAddon(true); webgl.onContextLoss(() => { // GPU context gone and not restored: drop the renderer, xterm falls back to the DOM renderer with @@ -793,6 +804,10 @@ function TerminalCardView({ } }, [session.id, lifecycle]); + useEffect(() => { + if (!surfaceIsLive(lifecycle)) restoreRendererTransition(); + }, [lifecycle]); + useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; diff --git a/tests/terminal-renderer-transition.test.mjs b/tests/terminal-renderer-transition.test.mjs new file mode 100644 index 00000000..97f3e357 --- /dev/null +++ b/tests/terminal-renderer-transition.test.mjs @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { stripTypeScriptTypes } from "node:module"; +import { runInNewContext } from "node:vm"; +import test from "node:test"; + +// Execute the current transition with a clock whose animation frames can stop independently of timers. +const card = await readFile(new URL("../src/renderer/src/features/terminal/TerminalCard.tsx", import.meta.url), "utf8"); +const transitionSource = card.slice(card.indexOf(" const rendererTransitionRef ="), card.indexOf(" const lifecycle =")); + +function fixture({ render = true, reducedMotion = false } = {}) { + let now = 0; + let nextId = 0; + const timers = new Map(); + const frames = new Map(); + const document = new EventTarget(); + document.hidden = false; + const window = new EventTarget(); + Object.assign(window, { + document, + setTimeout(callback, ms) { const id = ++nextId; timers.set(id, { at: now + ms, callback }); return id; }, + clearTimeout(id) { timers.delete(id); }, + requestAnimationFrame(callback) { const id = ++nextId; frames.set(id, callback); return id; }, + cancelAnimationFrame(id) { frames.delete(id); }, + matchMedia: () => ({ matches: reducedMotion }) + }); + document.defaultView = window; + let renders = new Set(); + const terminal = { + rows: 24, + onRender(callback) { renders.add(callback); return { dispose() { renders.delete(callback); } }; }, + refresh() { if (render) for (const callback of [...renders]) callback(); } + }; + const snapshot = { + ownerDocument: document, + isConnected: true, + animation: null, + remove() { this.isConnected = false; }, + animate() { + this.animation = { onfinish: null, canceled: false, cancel() { this.canceled = true; } }; + return this.animation; + } + }; + const { start, cancel } = runInNewContext(stripTypeScriptTypes(`${transitionSource} + const onVisibilityChange = () => { if (document.hidden) restoreRendererTransition(); }; + document.addEventListener("visibilitychange", onVisibilityChange); + rendererTransitionRef.current = { + snapshot, frame: null, render: null, + fallback: window.setTimeout(restoreRendererTransition, 250), + animation: null, + visibilityCleanup: () => document.removeEventListener("visibilitychange", onVisibilityChange) + }; + ({ start: () => revealRendererTransition(terminal, snapshot), cancel: restoreRendererTransition });`), { + useRef: () => ({ current: null }), snapshot, terminal, window, document, + requestAnimationFrame: window.requestAnimationFrame, cancelAnimationFrame: window.cancelAnimationFrame + }); + const advance = ms => { + const until = now + ms; + for (;;) { + const next = [...timers].filter(([, timer]) => timer.at <= until).sort((a, b) => a[1].at - b[1].at)[0]; + if (!next) break; + timers.delete(next[0]); now = next[1].at; next[1].callback(); + } + now = until; + }; + const frame = () => { + const pending = [...frames.values()]; frames.clear(); + for (const callback of pending) callback(); + }; + return { snapshot, document, start, cancel, advance, frame, + pending: () => timers.size + frames.size + renders.size, + counts: () => ({ timers: timers.size, frames: frames.size, renders: renders.size }) }; +} + +test("a visible transition fades only after the new render and two paint frames", () => { + const h = fixture(); + h.start(); + assert.equal(h.snapshot.animation, null); + h.frame(); + assert.equal(h.snapshot.animation, null); + h.frame(); + assert.ok(h.snapshot.animation); + assert.equal(h.snapshot.isConnected, true); + h.snapshot.animation.onfinish(); + assert.equal(h.snapshot.isConnected, false); + assert.deepEqual(h.counts(), { timers: 0, frames: 0, renders: 0 }); +}); + +for (const render of [false, true]) { + test(`the cleanup deadline removes the snapshot without animation frames (render=${render})`, () => { + const h = fixture({ render }); + h.start(); h.advance(249); + assert.equal(h.snapshot.isConnected, true); + h.advance(1); + assert.equal(h.snapshot.isConnected, false); + assert.deepEqual(h.counts(), { timers: 0, frames: 0, renders: 0 }); + h.frame(); + assert.equal(h.snapshot.animation, null); + }); +} + +test("hiding the document removes the snapshot before any suspended frames or timers run", () => { + const h = fixture(); + h.start(); + h.document.hidden = true; + h.document.dispatchEvent(new Event("visibilitychange")); + assert.equal(h.snapshot.isConnected, false); + assert.deepEqual(h.counts(), { timers: 0, frames: 0, renders: 0 }); +}); + +test("teardown cancels a pending render, frames and an active fade", () => { + for (const phase of ["render", "frame", "fade"]) { + const h = fixture({ render: phase !== "render" }); + h.start(); + if (phase === "fade") { h.frame(); h.frame(); } + h.cancel(); h.cancel(); + assert.equal(h.snapshot.isConnected, false); + assert.deepEqual(h.counts(), { timers: 0, frames: 0, renders: 0 }); + if (h.snapshot.animation) assert.equal(h.snapshot.animation.canceled, true); + h.advance(1_000); h.frame(); + assert.equal(h.snapshot.isConnected, false); + } +}); + +test("reduced motion removes the snapshot after paint without starting an animation", () => { + const h = fixture({ reducedMotion: true }); + h.start(); h.frame(); h.frame(); + assert.equal(h.snapshot.isConnected, false); + assert.equal(h.snapshot.animation, null); + assert.deepEqual(h.counts(), { timers: 0, frames: 0, renders: 0 }); +}); From 9104e1eeaab7e041e13fa4e6e333eca43108b0f8 Mon Sep 17 00:00:00 2001 From: Yt4aZaveta Date: Thu, 1 Oct 2026 18:53:45 +0300 Subject: [PATCH 3/3] test(terminal): exercise renderer transition initializer --- docs/README.md | 1 + docs/terminal-renderer-transition.md | 11 +++++ tests/terminal-renderer-transition.test.mjs | 52 +++++++++++++++------ 3 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 docs/terminal-renderer-transition.md diff --git a/docs/README.md b/docs/README.md index 901def6a..cabf3fdb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ CanvasTTY is a spatial Electron desktop for real local terminals and AI-agent CL | [Create a pixel theme with an agent](pixel-skin-agent-start.md) | Zero-context agent brief, exact install ZIP, visual checks, and validation | | [Runtime plugins](plugins.md) | Manifest v1, permissions, HOME widgets, canvas apps, separate windows, player media/playlist APIs, SDK, and install flow | | [Metrics and telemetry](metrics-and-telemetry.md) | Subscription limits, session token usage, source priority, privacy, stale states, and tests | +| [Terminal renderer transition check](terminal-renderer-transition.md) | Manual hide/restore check for the DOM/WebGL transition snapshot | | [Security policy](../SECURITY.md) | Supported release, vulnerability reporting, local data boundaries, plugins, media grants, browser storage, and audit logs | | [Changelog](../CHANGELOG.md) | User-visible fixes and features by release | diff --git a/docs/terminal-renderer-transition.md b/docs/terminal-renderer-transition.md new file mode 100644 index 00000000..ced05f41 --- /dev/null +++ b/docs/terminal-renderer-transition.md @@ -0,0 +1,11 @@ +# Terminal renderer transition check + +The terminal card keeps a short DOM snapshot while xterm switches between its DOM and WebGL renderers. Use this manual check when changing renderer lifecycle code. + +1. Start CanvasTTY with a clean or disposable profile and open a terminal card. +2. Produce continuous output in the card, for example `yes renderer-check` followed by `Ctrl-C`. +3. Zoom or resize the canvas across the WebGL eligibility boundary several times. The transition should reveal the new renderer with a short fade and should not leave a stale frame over the live terminal. +4. Start another burst of output, hide the CanvasTTY window or switch applications before the fade completes, then restore the window. The snapshot must be gone and the newest terminal output must be visible immediately; no old frame may cover it. +5. Repeat the hide/restore step while the card is switching renderer and while several terminal cards are visible. Check that other cards keep rendering and that selection and pinned input still work. + +The automated coverage is in `tests/terminal-renderer-transition.test.mjs`. It exercises the normal render-to-paint path, the cleanup deadline when rendering or animation frames stop, document visibility cleanup, teardown in each transition phase, and reduced-motion behavior. The manual steps above remain necessary for the compositor-visible hide/restore result. diff --git a/tests/terminal-renderer-transition.test.mjs b/tests/terminal-renderer-transition.test.mjs index 97f3e357..5e5ac178 100644 --- a/tests/terminal-renderer-transition.test.mjs +++ b/tests/terminal-renderer-transition.test.mjs @@ -26,34 +26,56 @@ function fixture({ render = true, reducedMotion = false } = {}) { }); document.defaultView = window; let renders = new Set(); - const terminal = { - rows: 24, - onRender(callback) { renders.add(callback); return { dispose() { renders.delete(callback); } }; }, - refresh() { if (render) for (const callback of [...renders]) callback(); } + const makeClassList = (initial = []) => { + const values = new Set(initial); + return { + add(...names) { names.forEach(name => values.add(name)); }, + [Symbol.iterator]() { return values[Symbol.iterator](); } + }; }; const snapshot = { ownerDocument: document, - isConnected: true, + isConnected: false, animation: null, + classList: makeClassList(), + style: {}, + setAttribute() {}, + querySelectorAll() { return []; }, remove() { this.isConnected = false; }, animate() { this.animation = { onfinish: null, canceled: false, cancel() { this.canceled = true; } }; return this.animation; } }; - const { start, cancel } = runInNewContext(stripTypeScriptTypes(`${transitionSource} - const onVisibilityChange = () => { if (document.hidden) restoreRendererTransition(); }; - document.addEventListener("visibilitychange", onVisibilityChange); - rendererTransitionRef.current = { - snapshot, frame: null, render: null, - fallback: window.setTimeout(restoreRendererTransition, 250), - animation: null, - visibilityCleanup: () => document.removeEventListener("visibilitychange", onVisibilityChange) - }; - ({ start: () => revealRendererTransition(terminal, snapshot), cancel: restoreRendererTransition });`), { + const screen = { + offsetLeft: 0, + offsetTop: 0, + checkVisibility() { return true; }, + querySelectorAll() { return []; }, + cloneNode() { return snapshot; } + }; + const element = { + dataset: { renderer: "dom" }, + classList: makeClassList(), + querySelector() { return screen; }, + append(node) { node.isConnected = true; } + }; + const terminal = { + element, + rows: 24, + onRender(callback) { renders.add(callback); return { dispose() { renders.delete(callback); } }; }, + refresh() { if (render) for (const callback of [...renders]) callback(); } + }; + const { begin, reveal, cancel } = runInNewContext(stripTypeScriptTypes(`${transitionSource} + ({ begin: beginRendererTransition, reveal: revealRendererTransition, cancel: restoreRendererTransition });`), { useRef: () => ({ current: null }), snapshot, terminal, window, document, requestAnimationFrame: window.requestAnimationFrame, cancelAnimationFrame: window.cancelAnimationFrame }); + const start = () => { + const transitionSnapshot = begin(terminal); + assert.equal(transitionSnapshot, snapshot); + reveal(terminal, transitionSnapshot); + }; const advance = ms => { const until = now + ms; for (;;) {