fix(mobile): preserve live palettes on identical themes (#31355)

fix(mobile): skip identical terminal theme assignments
Preserve live TUI palette changes and contrast caches on repeated normalized host themes. Keep explicit foreground recovery repaints and published desktop contrast overrides.

Co-authored-by: bbingz <zzb@gxsmjx.com>
This commit is contained in:
Neil
2026-10-20 21:32:23 -07:00
committed by GitHub
co-authored by bbingz
parent 3ed0bb4dfc
commit 3fd90e94e0
4 changed files with 86 additions and 8 deletions
+14 -4
View File
@@ -172,10 +172,15 @@ export type TerminalDocumentThemeTarget = {
export function applyTerminalTheme(
scope: TerminalDocumentScope,
input: TerminalDocumentThemeMessage
input: TerminalDocumentThemeMessage,
force = false
) {
scope.terminalThemeInput = input
scope.terminalTheme = normalizeTerminalTheme(scope, input)
const nextTheme = normalizeTerminalTheme(scope, input)
const themeChanged = Object.keys(scope.defaultTheme).some(
(key) => scope.terminalTheme[key] !== nextTheme[key]
)
scope.terminalTheme = nextTheme
const background = scope.terminalTheme.background || TERMINAL_BACKGROUND_FALLBACK
scope.paintDocumentBackground(background)
// Why prefer the published value: the desktop user may have lowered or disabled the floor (#10754);
@@ -186,7 +191,12 @@ export function applyTerminalTheme(
scope.terminalMinimumContrastRatio =
publishedFloor === null ? resolveTerminalContrastFloor(background) : publishedFloor
if (scope.term) {
scope.term.options.theme = scope.terminalTheme
scope.term.options.minimumContrastRatio = scope.terminalMinimumContrastRatio
// Reassigning the theme resets live OSC palette changes; visibility recovery still repaints.
if (force || themeChanged) {
scope.term.options.theme = scope.terminalTheme
}
if (scope.term.options.minimumContrastRatio !== scope.terminalMinimumContrastRatio) {
scope.term.options.minimumContrastRatio = scope.terminalMinimumContrastRatio
}
}
}
@@ -91,7 +91,7 @@ function onDocumentVisibilityChange(scope: TerminalDocumentScope) {
}
// Why: iOS may restore the xterm model while discarding GPU pixels/theme
// paint state, so visibility must rebuild the atlas and repaint every row.
applyTerminalTheme(scope, scope.terminalThemeInput)
applyTerminalTheme(scope, scope.terminalThemeInput, true)
try {
if (scope.webglAddon && scope.webglAddon.clearTextureAtlas) {
scope.webglAddon.clearTextureAtlas()
@@ -7,6 +7,7 @@ import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { createTerminalDocumentScope } from './document/document-scope'
import { attachWebglAddon, startWebglRecovery } from './document/webgl-recovery'
import { XTERM_HTML } from './terminal-webview-html'
import { applyTerminalTheme } from './document/terminal-theme'
function createWebglRecoveryHarness(failSecondAttach = false) {
const timers: Array<() => void> = []
@@ -209,17 +210,19 @@ describe('terminal WebView bundled engine', () => {
it('reapplies theme, clears the active atlas, and refreshes when visible', () => {
const harness = createWebglRecoveryHarness()
applyTerminalTheme(harness.scope, harness.scope.terminalThemeInput)
const firstTheme = harness.term.options.theme
// Hidden: nothing is re-applied, because a repaint of an invisible terminal is wasted. happy-dom
// reports a visible document, so the hidden arm is the stub and the visible one is the default.
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
harness.fireVisibilityChange()
expect(harness.term.options.theme).toEqual({})
expect(harness.term.options.theme).toBe(firstTheme)
vi.restoreAllMocks()
harness.fireVisibilityChange()
// The theme is re-applied through the document's own path, read off the terminal it wrote to.
expect(harness.term.options.theme).not.toEqual({})
expect(harness.term.options.theme).not.toBe(firstTheme)
expect(harness.addons[0]?.clearTextureAtlas).toHaveBeenCalledTimes(1)
expect(harness.term.refresh).toHaveBeenCalledTimes(1)
})
@@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createTerminalDocumentScope } from './document/document-scope'
import { applyTerminalTheme, resolveTerminalContrastFloor } from './document/terminal-theme'
import type {
@@ -113,3 +113,68 @@ describe('mobile terminal-webview contrast floor gate', () => {
})
})
})
describe('mobile terminal theme idempotence', () => {
function countingThemeScope() {
const term: TerminalDocumentThemeTarget = { options: { minimumContrastRatio: DARK_FLOOR } }
const scope = themeScope(term)
scope.terminalTheme = { ...scope.defaultTheme }
term.options.theme = scope.terminalTheme
const themeWrite = vi.fn()
const contrastWrite = vi.fn()
let theme = term.options.theme
let contrast = term.options.minimumContrastRatio
Object.defineProperty(term.options, 'theme', {
get: () => theme,
set: (next) => {
themeWrite(next)
theme = next
},
configurable: true
})
Object.defineProperty(term.options, 'minimumContrastRatio', {
get: () => contrast,
set: (next) => {
contrastWrite(next)
contrast = next
},
configurable: true
})
return { scope, term, themeWrite, contrastWrite }
}
it('preserves a live palette through repeated equal normalized host themes', () => {
const { scope, term, themeWrite, contrastWrite } = countingThemeScope()
const livePalette = term.options.theme
const second = { theme: { foreground: '#c0caf5', background: '#1a1b26', ignored: '#ffffff' } }
applyTerminalTheme(scope, { theme: { background: '#1a1b26' } })
applyTerminalTheme(scope, second)
expect(themeWrite).not.toHaveBeenCalled()
expect(contrastWrite).not.toHaveBeenCalled()
expect(term.options.theme).toBe(livePalette)
expect(scope.terminalThemeInput).toBe(second)
})
it('changes palette and published contrast independently', () => {
const { scope, themeWrite, contrastWrite } = countingThemeScope()
applyTerminalTheme(scope, { theme: { background: '#282828' } })
expect(themeWrite).toHaveBeenCalledTimes(1)
expect(contrastWrite).not.toHaveBeenCalled()
applyTerminalTheme(scope, { theme: { background: '#282828' }, minimumContrastRatio: 1 })
expect(themeWrite).toHaveBeenCalledTimes(1)
expect(contrastWrite).toHaveBeenCalledExactlyOnceWith(1)
})
it('forces an identical palette for visibility recovery', () => {
const { scope, themeWrite } = countingThemeScope()
applyTerminalTheme(scope, { theme: { ...scope.defaultTheme } }, true)
expect(themeWrite).toHaveBeenCalledTimes(1)
})
it('retains theme and published contrast before terminal construction', () => {
const scope = themeScope()
applyTerminalTheme(scope, { theme: { background: '#282828' }, minimumContrastRatio: 1 })
expect(scope.terminalTheme.background).toBe('#282828')
expect(scope.terminalMinimumContrastRatio).toBe(1)
})
})