fix(child-windows): reduce startup flicker with window preloading

This commit is contained in:
leewei committed 2026-05-28 11:37:11 +08:00
1 parent f5e799ce85
commit 56adfd528a
9 files changed
+275 -36

No files matched your search

+18 -1
View File
@@ -6,6 +6,23 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NyaTerm</title>
<style>
html,
body,
#root {
margin: 0;
height: 100%;
background-color: var(--df-bootstrap-bg, #0d1117);
}
</style>
<script>
try {
var bootstrapBg = localStorage.getItem("df-theme-background");
if (bootstrapBg) {
document.documentElement.style.setProperty("--df-bootstrap-bg", bootstrapBg);
}
} catch {}
</script>
</head>
<body>
@@ -13,4 +30,4 @@
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>
+9
View File
@@ -24,6 +24,7 @@ pub struct ChildWindowOptions {
height: Option<f64>,
resizable: Option<bool>,
always_on_top: Option<bool>,
background_color: Option<String>,
}
#[tauri::command]
@@ -90,6 +91,14 @@ pub async fn open_child_window(
.resizable(options.resizable.unwrap_or(true))
.always_on_top(options.always_on_top.unwrap_or(false));
if let Some(color) = options
.background_color
.as_deref()
.and_then(|value| value.parse::<tauri::webview::Color>().ok())
{
builder = builder.background_color(color);
}
#[cfg(target_os = "macos")]
{
builder = builder
+28
View File
@@ -24,6 +24,7 @@ import {
NON_PANEL_IDS,
type TrayAction,
} from "./lib/appWorkspace";
import { preloadModalChildWindowPages } from "./lib/childWindowPreload";
import { getErrorMessage, shouldPromptConnectionEditOnFailure } from "./lib/errors";
import { invoke } from "./lib/invoke";
import { logger } from "./lib/logger";
@@ -50,6 +51,7 @@ import {
openNewSession,
openNewSessionWithTarget,
openSettings,
prewarmSettingsWindow,
} from "./lib/windowManager";
import {
collectSessionPanes,
@@ -138,6 +140,32 @@ function App() {
});
}, [settingsLoaded]);
useEffect(() => {
if (!settingsLoaded) return;
const scheduleIdle =
window.requestIdleCallback ??
((callback: IdleRequestCallback) =>
window.setTimeout(
() =>
callback({
didTimeout: false,
timeRemaining: () => 0,
}),
800,
));
const cancelIdle = window.cancelIdleCallback ?? window.clearTimeout;
const idleId = scheduleIdle(() => {
preloadModalChildWindowPages();
void prewarmSettingsWindow();
});
return () => {
cancelIdle(idleId);
};
}, [settingsLoaded]);
useEffect(() => {
if (appSettings.ui.language && appSettings.ui.language !== i18n.language) {
i18n.changeLanguage(appSettings.ui.language);
+57 -15
View File
@@ -1,6 +1,9 @@
import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { lazy, Suspense, useEffect } from "react";
import { lazy, type ReactNode, Suspense, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useApp } from "./context/AppContext";
import { loadChildWindowPage } from "./lib/childWindowPreload";
import { isModalChildLabel, prepareForModalChildClose } from "./lib/windowManager";
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
@@ -8,24 +11,63 @@ const NewSessionPage = lazy(() => import("./pages/NewSessionPage"));
const QuickCommandPage = lazy(() => import("./pages/QuickCommandPage"));
const AutoUploadPage = lazy(() => import("./pages/FileUploadPage"));
const PAGES: Record<string, React.ComponentType<any>> = {
const PAGES: Record<string, React.ComponentType> = {
settings: SettingsPage,
"new-session": NewSessionPage,
"quick-command": QuickCommandPage,
"auto-upload": AutoUploadPage,
};
function ReadyChildWindow({ children }: { children: ReactNode }) {
const revealedRef = useRef(false);
useEffect(() => {
const currentWindow = getCurrentWindow();
const params = new URLSearchParams(window.location.search);
const isPrewarm = params.get("prewarm") === "1";
const frameId = window.requestAnimationFrame(() => {
if (revealedRef.current) return;
revealedRef.current = true;
if (isPrewarm) {
void emit("child-window-ready", { label: currentWindow.label });
return;
}
void currentWindow
.show()
.then(() => currentWindow.setFocus())
.then(() => {
void emit("child-window-ready", { label: currentWindow.label });
})
.then(() => {
void emit("child-window-opened", { label: currentWindow.label });
})
.catch(() => {});
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, []);
return children;
}
export default function ChildWindowRouter({ windowType }: { windowType: string }) {
const { t } = useTranslation();
const { settingsLoaded } = useApp();
const Page = PAGES[windowType];
useEffect(() => {
void loadChildWindowPage(windowType);
}, [windowType]);
useEffect(() => {
const currentWindow = getCurrentWindow();
let unlistenCloseRequested: (() => void) | undefined;
let programmaticClose = false;
currentWindow.show().catch(() => {});
currentWindow
.onCloseRequested(async (event) => {
if (programmaticClose || !isModalChildLabel(currentWindow.label)) return;
@@ -49,21 +91,21 @@ export default function ChildWindowRouter({ windowType }: { windowType: string }
if (!Page) {
return (
<div className="h-screen flex items-center justify-center text-muted-foreground">
{t("common.unknownWindowType")}: {windowType}
</div>
<ReadyChildWindow>
<div className="h-screen flex items-center justify-center text-muted-foreground">
{t("common.unknownWindowType")}: {windowType}
</div>
</ReadyChildWindow>
);
}
if (!settingsLoaded) return null;
return (
<Suspense
fallback={
<div className="h-screen flex items-center justify-center text-muted-foreground text-sm">
{t("common.loading")}
</div>
}
>
<Page />
<Suspense fallback={null}>
<ReadyChildWindow>
<Page />
</ReadyChildWindow>
</Suspense>
);
}
+1 -6
View File
@@ -382,15 +382,10 @@ export default function AppLayout({
{dialogs.modalChildWindowCount > 0 && (
<div
className="fixed inset-0 z-[9998]"
className="fixed inset-0 z-[9998] bg-black/50"
onMouseDown={() => {
void bounceTopModalWindow();
}}
style={{
backgroundColor: "rgba(0, 0, 0, 0.3)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
}}
/>
)}
+4
View File
@@ -27,6 +27,7 @@ interface ThemeContextType {
const ThemeContext = createContext<ThemeContextType | null>(null);
export const THEME_CACHE_KEY = "df-theme-id";
export const THEME_BACKGROUND_CACHE_KEY = "df-theme-background";
/** Inject all theme colors as CSS custom properties on :root */
export function applyThemeToDOM(colors: ThemeColors) {
@@ -53,6 +54,9 @@ export function applyThemeToDOM(colors: ThemeColors) {
root.setProperty("--df-shadow", colors.shadow);
root.setProperty("--df-scroll-thumb", colors.scrollThumb);
root.setProperty("--df-accent", colors.accent);
try {
localStorage.setItem(THEME_BACKGROUND_CACHE_KEY, colors.bg);
} catch {}
}
/** Provides theme, themeName, setTheme. Syncs with appSettings.appearance.theme from backend. */
+16
View File
@@ -0,0 +1,16 @@
const PAGE_LOADERS: Record<string, () => Promise<unknown>> = {
settings: () => import("@/pages/SettingsPage"),
"new-session": () => import("@/pages/NewSessionPage"),
"quick-command": () => import("@/pages/QuickCommandPage"),
"auto-upload": () => import("@/pages/FileUploadPage"),
};
export function loadChildWindowPage(windowType: string) {
return PAGE_LOADERS[windowType]?.();
}
export function preloadModalChildWindowPages() {
void PAGE_LOADERS.settings();
void PAGE_LOADERS["new-session"]();
void PAGE_LOADERS["quick-command"]();
}
+131 -14
View File
@@ -1,4 +1,4 @@
import { emit } from "@tauri-apps/api/event";
import { emit, listen } from "@tauri-apps/api/event";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import {
getCurrentWindow,
@@ -6,8 +6,8 @@ import {
UserAttentionType,
} from "@tauri-apps/api/window";
import i18n from "../i18n";
import { isMacOS } from "./platform";
import { invoke } from "./invoke";
import { isMacOS } from "./platform";
interface ChildWindowOptions {
label: string;
@@ -21,6 +21,15 @@ interface ChildWindowOptions {
const MAIN_WINDOW_LABEL = "main";
const AUTO_UPLOAD_WINDOW_PREFIX = "auto-upload-";
const MODAL_CHILD_LABELS = new Set(["settings", "new-session", "quick-command"]);
const CHILD_WINDOW_READY_TIMEOUT_MS = 10000;
const readyChildWindowLabels = new Set<string>();
const registeredDestroyedHandlers = new Set<string>();
void listen<{ label: string }>("child-window-ready", ({ payload }) => {
if (isModalChildLabel(payload.label)) {
readyChildWindowLabels.add(payload.label);
}
});
export function isModalChildLabel(label: string) {
return MODAL_CHILD_LABELS.has(label) || label.startsWith(AUTO_UPLOAD_WINDOW_PREFIX);
@@ -30,6 +39,10 @@ function needsAlwaysOnTop(label: string) {
return label.startsWith(AUTO_UPLOAD_WINDOW_PREFIX);
}
function getChildWindowBackgroundColor() {
return getComputedStyle(document.documentElement).getPropertyValue("--df-bg").trim() || "#0d1117";
}
async function getMainWindow() {
return (await WebviewWindow.getByLabel(MAIN_WINDOW_LABEL)) ?? getCurrentWindow();
}
@@ -67,9 +80,11 @@ async function applyModalWindowState(excludedLabel?: string) {
if (hasModalChild) {
const topModalWindow = remainingModalWindows[remainingModalWindows.length - 1];
await topModalWindow.show().catch(() => {});
await topModalWindow.setAlwaysOnTop(needsAlwaysOnTop(topModalWindow.label)).catch(() => {});
await topModalWindow.setFocus().catch(() => {});
const isVisible = await topModalWindow.isVisible().catch(() => false);
if (isVisible) {
await topModalWindow.setFocus().catch(() => {});
}
return;
}
@@ -77,6 +92,61 @@ async function applyModalWindowState(excludedLabel?: string) {
await mainWindow.setFocus().catch(() => {});
}
function attachChildWindowDestroyedHandler(label: string, win: WebviewWindow) {
if (registeredDestroyedHandlers.has(label)) return;
registeredDestroyedHandlers.add(label);
win.once("tauri://destroyed", () => {
registeredDestroyedHandlers.delete(label);
readyChildWindowLabels.delete(label);
emit("child-window-closed", { label });
void syncMainWindowModalState();
});
}
function waitForChildWindowReady(label: string) {
if (readyChildWindowLabels.has(label)) return Promise.resolve(true);
return new Promise<boolean>((resolve) => {
let settled = false;
let unlistenReady: (() => void) | undefined;
const timeout = window.setTimeout(() => {
if (settled) return;
settled = true;
unlistenReady?.();
resolve(false);
}, CHILD_WINDOW_READY_TIMEOUT_MS);
listen<{ label: string }>("child-window-ready", ({ payload }) => {
if (payload.label !== label || settled) return;
settled = true;
window.clearTimeout(timeout);
unlistenReady?.();
resolve(true);
}).then((unlisten) => {
if (settled) {
unlisten();
return;
}
unlistenReady = unlisten;
});
});
}
async function revealChildWindow(win: WebviewWindow, label: string) {
const isReady = await waitForChildWindowReady(label);
if (!isReady) {
await win.close().catch(() => {});
return;
}
await win.show().catch(() => {});
await win.setFocus().catch(() => {});
emit("child-window-opened", { label });
await syncMainWindowModalState().catch(() => {});
}
export async function syncMainWindowModalState() {
await applyModalWindowState();
}
@@ -90,6 +160,9 @@ export async function bounceTopModalWindow() {
const topModalWindow = modalWindows[modalWindows.length - 1];
if (!topModalWindow) return;
const isVisible = await topModalWindow.isVisible().catch(() => false);
if (!isVisible) return;
await topModalWindow.requestUserAttention(UserAttentionType.Critical).catch(() => {});
await topModalWindow.setAlwaysOnTop(needsAlwaysOnTop(topModalWindow.label)).catch(() => {});
await topModalWindow.setFocus().catch(() => {});
@@ -99,10 +172,15 @@ export async function openChildWindow(opts: ChildWindowOptions) {
const existing = await WebviewWindow.getByLabel(opts.label);
if (existing) {
await existing.setTitle(opts.title).catch(() => {});
await existing.show().catch(() => {});
await existing.setAlwaysOnTop(needsAlwaysOnTop(opts.label)).catch(() => {});
await existing.setFocus().catch(() => {});
await syncMainWindowModalState().catch(() => {});
const isVisible = await existing.isVisible().catch(() => true);
if (isVisible) {
await existing.show().catch(() => {});
await existing.setFocus().catch(() => {});
await syncMainWindowModalState().catch(() => {});
} else {
await revealChildWindow(existing, opts.label);
}
return existing;
}
await invoke("open_child_window", {
@@ -114,6 +192,7 @@ export async function openChildWindow(opts: ChildWindowOptions) {
height: opts.height ?? 560,
resizable: opts.resizable ?? true,
alwaysOnTop: needsAlwaysOnTop(opts.label),
backgroundColor: getChildWindowBackgroundColor(),
},
});
@@ -122,29 +201,67 @@ export async function openChildWindow(opts: ChildWindowOptions) {
throw new Error(`Failed to create child window: ${opts.label}`);
}
emit("child-window-opened", { label: opts.label });
await win.setAlwaysOnTop(needsAlwaysOnTop(opts.label)).catch(() => {});
await win.setFocus().catch(() => {});
await syncMainWindowModalState().catch(() => {});
attachChildWindowDestroyedHandler(opts.label, win);
const readyTimeout = window.setTimeout(async () => {
const isVisible = await win.isVisible().catch(() => true);
if (isVisible) return;
await win.close().catch(() => {});
}, CHILD_WINDOW_READY_TIMEOUT_MS);
win.once("tauri://destroyed", () => {
emit("child-window-closed", { label: opts.label });
void syncMainWindowModalState();
window.clearTimeout(readyTimeout);
});
return win;
}
export function openSettings(tab?: string) {
export async function openSettings(tab?: string) {
const url = tab
? `index.html?window=settings&tab=${encodeURIComponent(tab)}`
: "index.html?window=settings";
return openChildWindow({
const win = await openChildWindow({
label: "settings",
title: i18n.t("settings.title"),
url,
width: 800,
height: 560,
});
if (tab) {
emit("settings-open-tab", { tab });
}
return win;
}
export async function prewarmSettingsWindow() {
if (await WebviewWindow.getByLabel("settings")) return;
await invoke("open_child_window", {
options: {
label: "settings",
title: i18n.t("settings.title"),
url: "index.html?window=settings&prewarm=1",
width: 800,
height: 560,
resizable: true,
alwaysOnTop: false,
backgroundColor: getChildWindowBackgroundColor(),
},
});
const win = await WebviewWindow.getByLabel("settings");
if (!win) return;
attachChildWindowDestroyedHandler("settings", win);
window.setTimeout(async () => {
const isReady = readyChildWindowLabels.has("settings");
const isVisible = await win.isVisible().catch(() => true);
if (!isReady && !isVisible) {
await win.close().catch(() => {});
}
}, CHILD_WINDOW_READY_TIMEOUT_MS);
}
export interface NewSessionTarget {
+11
View File
@@ -1,3 +1,4 @@
import { listen } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import {
type ComponentType,
@@ -94,6 +95,16 @@ export default function SettingsPage() {
}
}, [activeTab]);
useEffect(() => {
const unlisten = listen<{ tab: string }>("settings-open-tab", ({ payload }) => {
setActiveTab(payload.tab === "ai" ? "ai-general" : payload.tab);
});
return () => {
unlisten.then((dispose) => dispose());
};
}, []);
type SettingsCategory = {
id: string;
label: string;