From 946b6e58fd58897036f8591a84ab0061cc5dcfee Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:16:48 +0000 Subject: [PATCH] Keep transcript colors in sync after a hidden theme change (#8199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(app): preserve adaptive theme events while hidden Read the current scheme through fresh matchMedia lists so hidden runtime reads do not consume the pending events on listener-owned lists. Carry Dan Sabin's fix from #7172 into the current combined Unistyles patch, and cover both transition directions with a real Electron regression in the existing desktop browser suite. Co-authored-by: Dan Sabin * test(desktop): run adaptive theme regression on Linux Electron retains --no-sandbox in argv, so the positional lookup used the script filename as the artifact directory. Read the final argument so the same real Electron regression exercises Linux as well as macOS. Co-authored-by: Nurşat Bülbül <85260051+Nurshot@users.noreply.github.com> Co-authored-by: Dan Sabin --------- Co-authored-by: Nurshot <85260051+Nurshot@users.noreply.github.com> Co-authored-by: Dan Sabin Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- docs/unistyles.md | 8 + .../desktop/e2e/appearance-theme.electron.mjs | 158 ++++++++++++++++++ packages/desktop/e2e/browser-tabs.e2e.mjs | 9 +- patches/react-native-unistyles+3.2.4.patch | 71 ++++++++ scripts/postinstall-patches.mjs | 1 + 5 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 packages/desktop/e2e/appearance-theme.electron.mjs diff --git a/docs/unistyles.md b/docs/unistyles.md index 3daae333df..6be82f70b0 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -358,6 +358,14 @@ export to `src`, but browser and Electron Metro resolve its `browser` export to `lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves Electron running the old code. +The adaptive-theme patch keeps `colorScheme` reads off the media-query lists +owned by Unistyles' change listeners. Chromium defers their events while a window +is hidden; reading `matches` on those same lists can consume the pending change. +CSS variables then follow the OS theme while `withUnistyles` props retain the old +colors. Keep this patch until upstream reads the scheme through separate lists. +The desktop browser E2E checks both transition directions in an isolated Electron +process, because Playwright's focus emulation prevents real hidden-page behavior. + Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in `patches/` is inert unless that script knows which installed package activates it. diff --git a/packages/desktop/e2e/appearance-theme.electron.mjs b/packages/desktop/e2e/appearance-theme.electron.mjs new file mode 100644 index 0000000000..426c50f355 --- /dev/null +++ b/packages/desktop/e2e/appearance-theme.electron.mjs @@ -0,0 +1,158 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import electronPath from "electron"; +import { build } from "esbuild"; + +export async function runAdaptiveThemeRegression(artifactDir) { + const { outputFiles } = await build({ + stdin: { + resolveDir: fileURLToPath(new URL("../../app", import.meta.url)), + contents: ` + import { createElement } from "react"; + import { createRoot } from "react-dom/client"; + import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; + StyleSheet.configure({ + themes: { + dark: { foreground: "#fafafa", background: "#181b1a" }, + light: { foreground: "#1a1a1e", background: "#ffffff" }, + }, + settings: { adaptiveThemes: true }, + }); + const Message = withUnistyles(({ color }) => + createElement("p", { style: { color } }, "Theme-reactive message")); + createRoot(document.getElementById("root")).render(createElement(Message, { + style: { margin: 0 }, + uniProps: theme => ({ color: theme.foreground }), + })); + window.readThemeForTest = () => UnistylesRuntime.themeName; + `, + }, + bundle: true, + write: false, + format: "iife", + platform: "browser", + alias: { "react-native": "react-native-web" }, + define: { __DEV__: "false" }, + resolveExtensions: [".web.js", ".js", ".json"], + }); + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-adaptive-theme-")); + try { + await fs.writeFile(path.join(directory, "renderer.js"), outputFiles[0].text); + await fs.writeFile(path.join(directory, "main.cjs"), `(${electronRegression.toString()})()`); + // A separate process avoids Playwright's focus emulation, which keeps hidden pages visible. + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + const args = [ + ...(process.platform === "linux" ? ["--no-sandbox"] : []), + path.join(directory, "main.cjs"), + artifactDir, + ]; + const command = process.platform === "linux" ? "xvfb-run" : electronPath; + const commandArgs = + process.platform === "linux" + ? ["-a", "--server-args=-screen 0 1280x800x24", electronPath, ...args] + : args; + await promisify(execFile)(command, commandArgs, { + env, + timeout: 30_000, + }); + return JSON.parse(await fs.readFile(path.join(artifactDir, "adaptive-theme.json"), "utf8")); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +} + +async function electronRegression() { + const { app, BrowserWindow, nativeTheme } = require("electron"); + const assert = require("node:assert/strict"); + const files = require("node:fs"); + const paths = require("node:path"); + const { setTimeout: delay } = require("node:timers/promises"); + app.setPath("userData", paths.join(__dirname, "profile")); + app.on("window-all-closed", () => {}); + // Electron keeps Linux's --no-sandbox in argv; the artifact directory is always last. + const artifactDir = process.argv.at(-1); + let window; + const observations = []; + const colors = { + dark: { foreground: "rgb(250, 250, 250)", background: "rgb(24, 27, 26)" }, + light: { foreground: "rgb(26, 26, 30)", background: "rgb(255, 255, 255)" }, + }; + async function read(expression) { + return window.webContents.executeJavaScript(expression); + } + async function expectValue(expression, expected) { + let actual; + for (let attempt = 0; attempt < 50; attempt++) { + actual = await read(expression); + if (actual === expected) return; + await delay(50); + } + assert.equal(actual, expected, expression); + } + try { + await app.whenReady(); + if (process.platform === "darwin") { + app.setActivationPolicy("accessory"); + app.dock.hide(); + } + for (const [from, to] of [ + ["dark", "light"], + ["light", "dark"], + ]) { + nativeTheme.themeSource = from; + window = new BrowserWindow({ width: 600, height: 200, show: false, skipTaskbar: true }); + await window.loadURL( + "data:text/html," + + encodeURIComponent(` + +
`), + ); + await read(files.readFileSync(paths.join(__dirname, "renderer.js"), "utf8")); + window.showInactive(); + const foreground = + "document.querySelector('p') && getComputedStyle(document.querySelector('p')).color"; + const background = "getComputedStyle(document.body).backgroundColor"; + await expectValue(foreground, colors[from].foreground); + await expectValue(background, colors[from].background); + window.hide(); + await expectValue("document.visibilityState", "hidden"); + nativeTheme.themeSource = to; + await expectValue(`matchMedia('(prefers-color-scheme: ${to})').matches`, true); + // Reading the public runtime while hidden must not consume the pending theme notification. + assert.equal(await read("window.readThemeForTest()"), to); + window.showInactive(); + await expectValue("document.visibilityState", "visible"); + await expectValue(background, colors[to].background); + try { + await expectValue(foreground, colors[to].foreground); + } finally { + files.writeFileSync( + paths.join(artifactDir, `theme-${from}-to-${to}.png`), + (await window.webContents.capturePage()).toPNG(), + ); + } + observations.push({ + from, + to, + foreground: await read(foreground), + background: await read(background), + }); + window.destroy(); + window = undefined; + } + files.writeFileSync( + paths.join(artifactDir, "adaptive-theme.json"), + JSON.stringify(observations, null, 2), + ); + app.exit(0); + } catch (error) { + console.error(error); + window?.destroy(); + app.exit(1); + } +} diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs index 78d2fa978e..28afbab94f 100644 --- a/packages/desktop/e2e/browser-tabs.e2e.mjs +++ b/packages/desktop/e2e/browser-tabs.e2e.mjs @@ -14,6 +14,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { chromium } from "playwright"; import { hideDeveloperToasts } from "../../app/e2e/support/developer-ui.mjs"; import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs"; +import { runAdaptiveThemeRegression } from "./appearance-theme.electron.mjs"; import { runSettingsScrollRegression } from "./settings-scroll.electron.mjs"; import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs"; @@ -1241,6 +1242,7 @@ async function main() { return; } + const adaptiveTheme = await runAdaptiveThemeRegression(artifactDir); await runAppearanceFontSizeRegression(page); const callerAgentId = await createCallerAgent(daemonPort); @@ -1265,7 +1267,12 @@ async function main() { artifactDir, }); const pluginLinks = await checkPluginLinks(); - writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks }); + writeJson(path.join(artifactDir, "result.json"), { + ...report, + settingsMemory, + pluginLinks, + adaptiveTheme, + }); console.log( `Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`, ); diff --git a/patches/react-native-unistyles+3.2.4.patch b/patches/react-native-unistyles+3.2.4.patch index 7f40053f19..1930a4db84 100644 --- a/patches/react-native-unistyles+3.2.4.patch +++ b/patches/react-native-unistyles+3.2.4.patch @@ -486,3 +486,74 @@ diff --git a/node_modules/react-native-unistyles/lib/commonjs/core/withUnistyles ...(narrowedProps.style ? { style: styleSecrets.styles } : {}), +diff --git a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js +index 305fa49..5957aa5 100644 +--- a/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js ++++ b/node_modules/react-native-unistyles/lib/commonjs/web/runtime.js +@@ -35,10 +35,17 @@ class UnistylesRuntime { + return this.darkMedia; + } + get colorScheme() { ++ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers ++ // MediaQueryList change events while the document is hidden and drops the pending event ++ // of any list whose `matches` was read before the document became visible again, which ++ // would silence the adaptive-theme listeners registered on those two lists. ++ if ((0, _utils.isServer)()) { ++ return _types.ColorScheme.Unspecified; ++ } + switch (true) { +- case this.getLightMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: light)').matches: + return _types.ColorScheme.Light; +- case this.getDarkMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: dark)').matches: + return _types.ColorScheme.Dark; + default: + return _types.ColorScheme.Unspecified; +diff --git a/node_modules/react-native-unistyles/lib/module/web/runtime.js b/node_modules/react-native-unistyles/lib/module/web/runtime.js +index 7f13a06..c487bee 100644 +--- a/node_modules/react-native-unistyles/lib/module/web/runtime.js ++++ b/node_modules/react-native-unistyles/lib/module/web/runtime.js +@@ -31,10 +31,17 @@ export class UnistylesRuntime { + return this.darkMedia; + } + get colorScheme() { ++ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers ++ // MediaQueryList change events while the document is hidden and drops the pending event ++ // of any list whose `matches` was read before the document became visible again, which ++ // would silence the adaptive-theme listeners registered on those two lists. ++ if (isServer()) { ++ return ColorScheme.Unspecified; ++ } + switch (true) { +- case this.getLightMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: light)').matches: + return ColorScheme.Light; +- case this.getDarkMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: dark)').matches: + return ColorScheme.Dark; + default: + return ColorScheme.Unspecified; +diff --git a/node_modules/react-native-unistyles/src/web/runtime.ts b/node_modules/react-native-unistyles/src/web/runtime.ts +index a16dac0..6519858 100644 +--- a/node_modules/react-native-unistyles/src/web/runtime.ts ++++ b/node_modules/react-native-unistyles/src/web/runtime.ts +@@ -41,8 +41,16 @@ export class UnistylesRuntime { + get colorScheme() { ++ // Read the scheme through fresh lists, never through lightMedia/darkMedia. Chromium defers ++ // MediaQueryList change events while the document is hidden and drops the pending event ++ // of any list whose `matches` was read before the document became visible again, which ++ // would silence the adaptive-theme listeners registered on those two lists. ++ if (isServer()) { ++ return ColorScheme.Unspecified ++ } ++ + switch (true) { +- case this.getLightMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: light)').matches: + return ColorScheme.Light +- case this.getDarkMedia()?.matches: ++ case window.matchMedia('(prefers-color-scheme: dark)').matches: + return ColorScheme.Dark + default: + return ColorScheme.Unspecified diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs index 435f6331e0..eca0fde25f 100644 --- a/scripts/postinstall-patches.mjs +++ b/scripts/postinstall-patches.mjs @@ -24,6 +24,7 @@ const patchedPackages = [ }, // Registry cleanup: remove after react-native-unistyles ships // https://github.com/jpudysz/react-native-unistyles/pull/1203. + // Adaptive themes: keep until colorScheme reads no longer consume listener-owned media changes. // withUnistyles: also keep the uniProps filter until upstream consumes its control prop. { nodeModulesPath: "node_modules/react-native-unistyles",