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",