mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 05:02:58 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15635531c6 | ||
|
|
b678e122d5 | ||
|
|
4351445467 | ||
|
|
9503537041 | ||
|
|
1aef1ec662 | ||
|
|
946b6e58fd |
@@ -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.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const withAndroidOrientation = require("./plugins/with-android-orientation");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const pkg = require("./package.json");
|
||||
@@ -142,6 +143,7 @@ export default {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
withAndroidOrientation,
|
||||
"expo-router",
|
||||
withPasteInput,
|
||||
withAndroidScroll,
|
||||
|
||||
@@ -59,3 +59,31 @@ describe("Android app config", () => {
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the Android manifest portrait for phone startup", async () => {
|
||||
const manifest = await resolveAndroidManifest();
|
||||
const activity = manifest.application[0].activity.find(
|
||||
(entry: ManifestEntry) => entry.$["android:name"] === ".MainActivity",
|
||||
);
|
||||
expect(activity.$["android:screenOrientation"]).toBe("portrait");
|
||||
}, 30_000);
|
||||
|
||||
it("updates the native orientation before launch and when screen size changes", async () => {
|
||||
const plugin = createRequire(import.meta.url)("./plugins/with-android-orientation.js");
|
||||
const config = plugin({ name: "test", slug: "test" });
|
||||
const result = await config.mods.android.mainActivity({
|
||||
modRequest: {},
|
||||
modResults: {
|
||||
language: "kt",
|
||||
contents:
|
||||
"class MainActivity {\n override fun onCreate(savedInstanceState: Bundle?) {\n super.onCreate(null)\n }\n}",
|
||||
},
|
||||
});
|
||||
expect(result.modResults.contents).toContain("smallestScreenWidthDp >= 600");
|
||||
expect(result.modResults.contents).toContain("SCREEN_ORIENTATION_UNSPECIFIED");
|
||||
expect(result.modResults.contents).toContain("SCREEN_ORIENTATION_PORTRAIT");
|
||||
expect(result.modResults.contents).toContain(
|
||||
"updateOrientation(resources.configuration)\n super.onCreate(null)",
|
||||
);
|
||||
expect(result.modResults.contents).toContain("updateOrientation(newConfig)");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const { withMainActivity } = require("expo/config-plugins");
|
||||
|
||||
module.exports = function withAndroidOrientation(config) {
|
||||
return withMainActivity(config, (mod) => {
|
||||
const activity = mod.modResults;
|
||||
if (activity.language !== "kt") throw new Error("Expected Kotlin MainActivity");
|
||||
const marker = " // Paseo orientation policy";
|
||||
if (!activity.contents.includes(marker)) {
|
||||
const anchor = " override fun onCreate(savedInstanceState: Bundle?)";
|
||||
if (!activity.contents.includes(anchor)) throw new Error("MainActivity anchor missing");
|
||||
activity.contents = activity.contents.replace(
|
||||
anchor,
|
||||
`${marker}
|
||||
private fun updateOrientation(configuration: android.content.res.Configuration) {
|
||||
val orientation = if (configuration.smallestScreenWidthDp >= 600)
|
||||
android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
else
|
||||
android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
if (requestedOrientation != orientation) requestedOrientation = orientation
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
updateOrientation(newConfig)
|
||||
}
|
||||
|
||||
${anchor}`,
|
||||
);
|
||||
if (!activity.contents.includes(" super.onCreate(null)"))
|
||||
throw new Error("MainActivity onCreate missing");
|
||||
activity.contents = activity.contents.replace(
|
||||
" super.onCreate(null)",
|
||||
" updateOrientation(resources.configuration)\n super.onCreate(null)",
|
||||
);
|
||||
}
|
||||
return mod;
|
||||
});
|
||||
};
|
||||
@@ -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(`<!doctype html>
|
||||
<style>body { background: var(--background); } p { font: 20px system-ui; }</style>
|
||||
<div id="root"></div>`),
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -8059,6 +8059,60 @@ test("global subscribers receive internal agent events only when they opt in", a
|
||||
unsubscribeOptedIn();
|
||||
});
|
||||
|
||||
test("agent removal notifications respect scoped subscriptions and internal visibility", () => {
|
||||
const manager = new AgentManager({ clients: {}, logger });
|
||||
const removedId = "00000000-0000-4000-8000-000000000901";
|
||||
const internalId = "00000000-0000-4000-8000-000000000902";
|
||||
const otherId = "00000000-0000-4000-8000-000000000903";
|
||||
const publicEvents: AgentManagerEvent[] = [];
|
||||
const allEvents: AgentManagerEvent[] = [];
|
||||
const scopedEvents: AgentManagerEvent[] = [];
|
||||
const internalEvents: AgentManagerEvent[] = [];
|
||||
const unrelatedEvents: AgentManagerEvent[] = [];
|
||||
const releases = [
|
||||
manager.subscribe((event) => publicEvents.push(event), { replayState: false }),
|
||||
manager.subscribe((event) => allEvents.push(event), {
|
||||
replayState: false,
|
||||
includeInternal: true,
|
||||
}),
|
||||
manager.subscribe((event) => scopedEvents.push(event), {
|
||||
replayState: false,
|
||||
agentId: removedId,
|
||||
}),
|
||||
manager.subscribe((event) => internalEvents.push(event), {
|
||||
replayState: false,
|
||||
agentId: internalId,
|
||||
}),
|
||||
manager.subscribe((event) => unrelatedEvents.push(event), {
|
||||
replayState: false,
|
||||
agentId: otherId,
|
||||
}),
|
||||
];
|
||||
try {
|
||||
manager.notifyAgentRemoved({ id: removedId, workspaceId: "workspace" });
|
||||
manager.notifyAgentRemoved({ id: internalId, workspaceId: "workspace", internal: true });
|
||||
const publicRemoval = {
|
||||
type: "agent_removed",
|
||||
agentId: removedId,
|
||||
workspaceId: "workspace",
|
||||
internal: false,
|
||||
};
|
||||
const internalRemoval = {
|
||||
type: "agent_removed",
|
||||
agentId: internalId,
|
||||
workspaceId: "workspace",
|
||||
internal: true,
|
||||
};
|
||||
expect(publicEvents).toEqual([publicRemoval]);
|
||||
expect(allEvents).toEqual([publicRemoval, internalRemoval]);
|
||||
expect(scopedEvents).toEqual([publicRemoval]);
|
||||
expect(internalEvents).toEqual([internalRemoval]);
|
||||
expect(unrelatedEvents).toEqual([]);
|
||||
} finally {
|
||||
for (const release of releases) release();
|
||||
}
|
||||
});
|
||||
|
||||
test("getAgent returns internal agents by ID", async () => {
|
||||
const internalAgentId = "00000000-0000-4000-8000-000000000107";
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
|
||||
@@ -236,6 +236,7 @@ export type {
|
||||
|
||||
export type AgentManagerEvent =
|
||||
| { type: "agent_state"; agent: ManagedAgent }
|
||||
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
|
||||
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
|
||||
| { type: "timeline_replacement"; agentId: string; epoch: string }
|
||||
| {
|
||||
@@ -3519,6 +3520,15 @@ export class AgentManager {
|
||||
await this.deleteCommittedTimeline(agentId);
|
||||
}
|
||||
|
||||
notifyAgentRemoved(agent: Pick<StoredAgentRecord, "id" | "workspaceId" | "internal">): void {
|
||||
this.dispatch({
|
||||
type: "agent_removed",
|
||||
agentId: agent.id,
|
||||
workspaceId: agent.workspaceId,
|
||||
internal: agent.internal === true,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCommittedTimeline(agentId: string): Promise<void> {
|
||||
await this.durableTimelineStore?.deleteAgent(agentId);
|
||||
}
|
||||
@@ -5376,7 +5386,7 @@ export class AgentManager {
|
||||
for (const subscriber of this.subscribers) {
|
||||
if (
|
||||
subscriber.agentId &&
|
||||
event.type === "agent_stream" &&
|
||||
(event.type === "agent_stream" || event.type === "agent_removed") &&
|
||||
subscriber.agentId !== event.agentId
|
||||
) {
|
||||
continue;
|
||||
@@ -5412,6 +5422,7 @@ export class AgentManager {
|
||||
|
||||
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
|
||||
if (event.type === "agent_state") return event.agent.internal === true;
|
||||
if (event.type === "agent_removed") return event.internal;
|
||||
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
|
||||
if (event.type !== "provider_subagent") return false;
|
||||
const parentAgentId =
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
@@ -251,23 +253,52 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
|
||||
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
|
||||
});
|
||||
|
||||
async function measureTimelineTail(wholeTurns: boolean) {
|
||||
const fixture = fileURLToPath(
|
||||
new URL("./test-utils/timeline-responsiveness.ts", import.meta.url),
|
||||
);
|
||||
const { stdout, stderr } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
["--expose-gc", "--import", "tsx", fixture, String(wholeTurns)],
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
process.stderr.write(stderr);
|
||||
return JSON.parse(stdout) as {
|
||||
result: ReturnType<InMemoryAgentTimelineStore["fetch"]>;
|
||||
blockingMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
it("excludes unrelated test-runner CPU from the timeline fetch budget", async () => {
|
||||
let unrelatedWork: ReturnType<typeof setImmediate>;
|
||||
const burnCpu = () => {
|
||||
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
|
||||
process,
|
||||
"threadCpuUsage",
|
||||
);
|
||||
const start = threadCpuUsage();
|
||||
let elapsed: NodeJS.CpuUsage;
|
||||
do {
|
||||
elapsed = threadCpuUsage(start);
|
||||
} while (elapsed.user + elapsed.system < 80_000);
|
||||
unrelatedWork = setImmediate(burnCpu);
|
||||
};
|
||||
unrelatedWork = setImmediate(burnCpu);
|
||||
try {
|
||||
const { result: tail, blockingMs } = await measureTimelineTail(false);
|
||||
expect(tail.rows).toHaveLength(200);
|
||||
expect(tail.startSeq).toBe(99_801);
|
||||
expect(tail.endSeq).toBe(100_000);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
} finally {
|
||||
clearImmediate(unrelatedWork);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
|
||||
async (wholeTurns) => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("large");
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
store.append("large", { type: "user_message", text: String(index) });
|
||||
}
|
||||
const { result: tail, blockingMs } = await measureMainThreadWork(
|
||||
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
|
||||
() => {
|
||||
let page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
for (let repeat = 1; repeat < 8; repeat++)
|
||||
page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
return page;
|
||||
},
|
||||
);
|
||||
const { result: tail, blockingMs } = await measureTimelineTail(wholeTurns);
|
||||
expect(tail.rows).toHaveLength(200);
|
||||
expect(tail.startSeq).toBe(99_801);
|
||||
expect(tail.endSeq).toBe(100_000);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js";
|
||||
import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js";
|
||||
|
||||
// Measure a cold fetch on its own thread, without Vitest reporting or callbacks.
|
||||
const wholeTurns = process.argv[2] === "true";
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("large");
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
store.append("large", { type: "user_message", text: String(index) });
|
||||
}
|
||||
// Finish fixture allocation and collect its temporary garbage before measuring.
|
||||
globalThis.gc!();
|
||||
await nextTurn();
|
||||
const measured = await measureMainThreadWork(
|
||||
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
|
||||
() => {
|
||||
let page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
for (let repeat = 1; repeat < 8; repeat++)
|
||||
page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
return page;
|
||||
},
|
||||
);
|
||||
|
||||
process.stdout.write(JSON.stringify(measured));
|
||||
@@ -1,7 +1,8 @@
|
||||
import { MockLoadTestAgentClient } from "./agent/providers/mock-load-test-agent.js";
|
||||
import { createPaseoApi } from "@getpaseo/client";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import type { z } from "zod";
|
||||
@@ -71,15 +72,18 @@ class SubscriptionPeer {
|
||||
async request(message: z.input<typeof SessionInboundMessageSchema>): Promise<void> {
|
||||
this.socket.send(JSON.stringify({ type: "session", message }));
|
||||
await expect
|
||||
.poll(() =>
|
||||
this.frames.some(
|
||||
(frame) =>
|
||||
frame.type === "session" &&
|
||||
"payload" in frame.message &&
|
||||
"requestId" in frame.message.payload &&
|
||||
"requestId" in message &&
|
||||
frame.message.payload.requestId === message.requestId,
|
||||
),
|
||||
.poll(
|
||||
() =>
|
||||
this.frames.some(
|
||||
(frame) =>
|
||||
frame.type === "session" &&
|
||||
"payload" in frame.message &&
|
||||
"requestId" in frame.message.payload &&
|
||||
"requestId" in message &&
|
||||
frame.message.payload.requestId === message.requestId,
|
||||
),
|
||||
// Diagnostics invoke external tools with their own multi-second deadlines.
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
@@ -119,6 +123,16 @@ class SubscriptionPeer {
|
||||
}
|
||||
}
|
||||
|
||||
function removedAgentIds(peer: SubscriptionPeer): string[] {
|
||||
return peer.frames.flatMap((frame) =>
|
||||
frame.type === "session" &&
|
||||
frame.message.type === "agent_update" &&
|
||||
frame.message.payload.kind === "remove"
|
||||
? [frame.message.payload.agentId]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
test("a pure list reply belongs only to its source socket in a shared logical session", async () => {
|
||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||
const peers: SubscriptionPeer[] = [];
|
||||
@@ -225,6 +239,96 @@ test("combined filtered agent lists create independent server-owned observations
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{
|
||||
state: "live",
|
||||
prepare: async (_manager: AgentManager, _agentId: string) => {},
|
||||
},
|
||||
{
|
||||
state: "stored-only",
|
||||
prepare: (manager: AgentManager, agentId: string) => manager.closeAgent(agentId),
|
||||
},
|
||||
])(
|
||||
"$state agent deletion reaches directory observers across logical sessions without broadcasting its reply",
|
||||
async ({ prepare }) => {
|
||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||
const admin = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.8.0",
|
||||
});
|
||||
const peers: SubscriptionPeer[] = [];
|
||||
try {
|
||||
await admin.connect();
|
||||
const workspace = (
|
||||
await admin.createWorkspace({ source: { kind: "directory", path: daemon.staticDir } })
|
||||
).workspace;
|
||||
if (!workspace) throw new Error("Workspace creation failed");
|
||||
const removed = await admin.createAgent({
|
||||
provider: "codex",
|
||||
cwd: daemon.staticDir,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
const survivor = await admin.createAgent({
|
||||
provider: "codex",
|
||||
cwd: daemon.staticDir,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
const sentinel = path.join(daemon.staticDir, "keep-project-file.txt");
|
||||
await writeFile(sentinel, "keep this project file\n");
|
||||
await prepare(daemon.daemon.agentManager, removed.id);
|
||||
const requester = await SubscriptionPeer.connect(daemon.port, "deletion-origin");
|
||||
const sibling = await SubscriptionPeer.connect(daemon.port, "deletion-origin");
|
||||
const observer = await SubscriptionPeer.connect(daemon.port, "deletion-observer");
|
||||
const legacy = await SubscriptionPeer.connect(daemon.port, "deletion-legacy", {
|
||||
owned_subscriptions: false,
|
||||
});
|
||||
peers.push(requester, sibling, observer, legacy);
|
||||
for (const [index, peer] of peers.entries()) {
|
||||
await peer.request({
|
||||
type: "fetch_agents_request",
|
||||
requestId: `directory-${index}`,
|
||||
subscribe: {},
|
||||
});
|
||||
const entries = peer.frames.flatMap((frame) =>
|
||||
frame.type === "session" && frame.message.type === "fetch_agents_response"
|
||||
? frame.message.payload.entries.map((entry) => entry.agent.id)
|
||||
: [],
|
||||
);
|
||||
expect(entries.sort()).toEqual([removed.id, survivor.id].sort());
|
||||
peer.frames.length = 0;
|
||||
}
|
||||
await requester.request({
|
||||
type: "delete_agent_request",
|
||||
requestId: "delete-observed-agent",
|
||||
agentId: removed.id,
|
||||
});
|
||||
await expect
|
||||
.poll(() => peers.map(removedAgentIds))
|
||||
.toEqual([[removed.id], [removed.id], [removed.id], [removed.id]]);
|
||||
const replies = peers.map((peer) =>
|
||||
peer.frames.flatMap((frame) =>
|
||||
frame.type === "session" && frame.message.type === "agent_deleted"
|
||||
? [frame.message.payload]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
expect(replies).toEqual([
|
||||
[{ agentId: removed.id, requestId: "delete-observed-agent" }],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
]);
|
||||
expect(await daemon.daemon.agentStorage.get(removed.id)).toBeNull();
|
||||
expect((await daemon.daemon.agentStorage.get(survivor.id))?.id).toBe(survivor.id);
|
||||
expect(await readFile(sentinel, "utf8")).toBe("keep this project file\n");
|
||||
} finally {
|
||||
for (const peer of peers) peer.close();
|
||||
await admin.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("workspace filters and identical queries coexist until their own release", async () => {
|
||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
||||
@@ -679,7 +783,8 @@ test("same-workspace terminal directory observers do not share a teardown slot",
|
||||
peer.frames.length = 0;
|
||||
const result = await admin.createTerminal(daemon.staticDir, "Observed terminal", undefined, {
|
||||
workspaceId: workspace.id,
|
||||
command: "/bin/cat",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
await expect
|
||||
@@ -728,7 +833,8 @@ test("terminal output has separate server IDs and binary slots, including in a s
|
||||
).workspace!;
|
||||
const result = await admin.createTerminal(daemon.staticDir, "Output ownership", undefined, {
|
||||
workspaceId: workspace.id,
|
||||
command: "/bin/cat",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.stdin.on('data', data => process.stdout.write(data))"],
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
const idle = await SubscriptionPeer.connect(daemon.port, "terminal-output-session");
|
||||
@@ -1423,7 +1529,7 @@ test("request outcomes for import and attention reach only their requesting sock
|
||||
});
|
||||
const requester = await SubscriptionPeer.connect(daemon.port, "reply-shared");
|
||||
const sibling = await SubscriptionPeer.connect(daemon.port, "reply-shared");
|
||||
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
||||
try {
|
||||
await admin.connect();
|
||||
await requester.request({
|
||||
@@ -1462,7 +1568,7 @@ test("request outcomes for import and attention reach only their requesting sock
|
||||
}),
|
||||
);
|
||||
const agent = await admin.createAgent({
|
||||
provider: "codex",
|
||||
provider: "mock",
|
||||
cwd: daemon.staticDir,
|
||||
title: "Attention",
|
||||
});
|
||||
|
||||
@@ -1992,6 +1992,24 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "agent_removed") {
|
||||
if (event.internal) return;
|
||||
void this.agentUpdates
|
||||
.removeAgent(event.agentId)
|
||||
.then(() => {
|
||||
if (event.workspaceId)
|
||||
return this.emitWorkspaceUpdateForWorkspaceId(event.workspaceId);
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId: event.agentId },
|
||||
"Failed to emit agent removal",
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "agent_state") {
|
||||
this.sessionLogger.trace(
|
||||
{
|
||||
@@ -3337,10 +3355,8 @@ export class Session {
|
||||
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`);
|
||||
|
||||
const knownWorkspaceId =
|
||||
this.agentManager.getAgent(agentId)?.workspaceId ??
|
||||
(await this.agentStorage.get(agentId))?.workspaceId ??
|
||||
null;
|
||||
const knownAgent =
|
||||
this.agentManager.getAgent(agentId) ?? (await this.agentStorage.get(agentId));
|
||||
|
||||
// File-backed storage still needs an early delete fence before closeAgent().
|
||||
beginAgentDeleteIfSupported(this.agentStorage, agentId);
|
||||
@@ -3361,6 +3377,8 @@ export class Session {
|
||||
try {
|
||||
await this.agentStorage.remove(agentId);
|
||||
await this.agentManager.deleteAgentState(agentId);
|
||||
// Directory removal is daemon-wide; agent_deleted below stays a source-scoped RPC reply.
|
||||
if (knownAgent) this.agentManager.notifyAgentRemoved(knownAgent);
|
||||
} catch (error) {
|
||||
this.sessionLogger.error({ err: error, agentId }, `Failed to fully delete agent ${agentId}`);
|
||||
}
|
||||
@@ -3372,12 +3390,6 @@ export class Session {
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
|
||||
await this.agentUpdates.removeAgent(agentId);
|
||||
|
||||
if (knownWorkspaceId) {
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(knownWorkspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { fileURLToPath } from "node:url";
|
||||
import { writeFile, truncate, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { build } from "esbuild";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import {
|
||||
readPaseoConfig,
|
||||
@@ -20,21 +21,40 @@ import {
|
||||
writePaseoConfigForEdit,
|
||||
} from "./paseo-config-file.js";
|
||||
|
||||
let probeDirectory: string;
|
||||
let probePath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
probeDirectory = mkdtempSync(join(tmpdir(), "paseo-config-probe-"));
|
||||
probePath = join(probeDirectory, "config-responsiveness.mjs");
|
||||
// Compile before starting the measured process. A runtime TypeScript loader
|
||||
// owns MessagePorts that the responsiveness probe otherwise charges as work.
|
||||
await build({
|
||||
entryPoints: [
|
||||
fileURLToPath(new URL("./test-fixtures/config-responsiveness.ts", import.meta.url)),
|
||||
],
|
||||
outfile: probePath,
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(probeDirectory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function measureConfigRead(directory: string) {
|
||||
const fixture = fileURLToPath(
|
||||
new URL("./test-fixtures/config-responsiveness.ts", import.meta.url),
|
||||
);
|
||||
const { stdout, stderr } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
["--import", "tsx", fixture, directory],
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
const { stdout, stderr } = await promisify(execFile)(process.execPath, [probePath, directory], {
|
||||
timeout: 15_000,
|
||||
});
|
||||
process.stderr.write(stderr);
|
||||
return JSON.parse(stdout) as {
|
||||
result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
||||
blockingMs: number;
|
||||
filesystemRequests: number;
|
||||
yields: number;
|
||||
workerMessages: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,7 +104,7 @@ describe("paseo config file substrate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes unrelated test-runner CPU from the config read budget", async () => {
|
||||
it("excludes test-runner and loader activity from the config read budget", async () => {
|
||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
|
||||
const unrelatedWork = new Promise<void>((resolve) => {
|
||||
setImmediate(() => {
|
||||
@@ -100,8 +120,10 @@ describe("paseo config file substrate", () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
|
||||
const { result, blockingMs, filesystemRequests, workerMessages } =
|
||||
await measureConfigRead(tempDir);
|
||||
await unrelatedWork;
|
||||
expect(workerMessages).toBe(0);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
config: { worktree: { setup: "npm ci" } },
|
||||
@@ -119,11 +141,13 @@ describe("paseo config file substrate", () => {
|
||||
worktree: { setup: "npm ci" },
|
||||
};
|
||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
|
||||
const { result, blockingMs, filesystemRequests, yields } = await measureConfigRead(tempDir);
|
||||
const { result, blockingMs, filesystemRequests, yields, workerMessages } =
|
||||
await measureConfigRead(tempDir);
|
||||
expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
|
||||
expect(blockingMs).toBeLessThan(20);
|
||||
expect(filesystemRequests).toBeGreaterThan(0);
|
||||
expect(yields).toBeGreaterThan(0);
|
||||
expect(workerMessages).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a 32MiB config with main-thread work below 20ms", async () => {
|
||||
@@ -131,9 +155,11 @@ describe("paseo config file substrate", () => {
|
||||
join(tempDir, "paseo.json"),
|
||||
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
|
||||
);
|
||||
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
|
||||
const { result, blockingMs, filesystemRequests, workerMessages } =
|
||||
await measureConfigRead(tempDir);
|
||||
expect(blockingMs).toBeLessThan(20);
|
||||
expect(filesystemRequests).toBeGreaterThan(0);
|
||||
expect(workerMessages).toBe(0);
|
||||
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { createHook, executionAsyncResource } from "node:async_hooks";
|
||||
import { MessagePort } from "node:worker_threads";
|
||||
import assert from "node:assert/strict";
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||
import { readPaseoConfigForEdit } from "../paseo-config-file.js";
|
||||
|
||||
// Keep Vitest transforms, reporting, and unrelated callbacks outside the measured
|
||||
// thread. Read the real file once, without warming the config snapshot or schema.
|
||||
// The parent bundles this fixture before launching plain Node, keeping test
|
||||
// runner callbacks and runtime loader messages outside the measured thread.
|
||||
// Read the real file once, without warming the config snapshot or schema.
|
||||
const directory = process.argv[2];
|
||||
assert.ok(directory, "Config directory is required");
|
||||
await nextTurn();
|
||||
let workerMessages = 0;
|
||||
const activity = createHook({
|
||||
before() {
|
||||
if (executionAsyncResource() instanceof MessagePort) workerMessages++;
|
||||
},
|
||||
}).enable();
|
||||
const measured = await measureMainThreadWork("project config main-thread work", () =>
|
||||
readPaseoConfigForEdit(directory),
|
||||
);
|
||||
process.stdout.write(JSON.stringify(measured));
|
||||
activity.disable();
|
||||
process.stdout.write(JSON.stringify({ ...measured, workerMessages }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user