fix(startup): reduce initial loading and unblock app restoration

This commit is contained in:
t8y2
2026-09-25 09:03:15 +08:00
parent 55c07f5852
commit 48d66d04d0
23 changed files with 745 additions and 123 deletions
+32 -7
View File
@@ -13,6 +13,10 @@
background-color: rgb(255 255 255);
}
body {
margin: 0;
}
html.dark,
html.dark body,
html.dark #root {
@@ -22,30 +26,47 @@
#startup-loading {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100vh;
align-items: center;
justify-content: center;
color: #6b7280;
font: 13px system-ui, sans-serif;
font:
14px system-ui,
sans-serif;
}
#startup-loading::before {
#startup-loading .startup-brand {
color: #111827;
font-size: 20px;
font-weight: 600;
letter-spacing: -0.5px;
}
html.dark #startup-loading .startup-brand {
color: #f3f4f6;
}
#startup-loading .startup-spinner {
width: 22px;
height: 22px;
margin-bottom: 12px;
box-sizing: border-box;
border: 2px solid rgba(107, 114, 128, 0.22);
border-top-color: currentColor;
border-radius: 999px;
content: "";
animation: dbx-startup-spin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
#startup-loading::before { animation: none; }
#startup-loading .startup-spinner {
animation: none;
}
}
@keyframes dbx-startup-spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
</style>
<script data-dbx-startup-theme>
@@ -98,7 +119,11 @@
</head>
<body>
<div id="root">
<div id="startup-loading" role="status" aria-live="polite">Loading…</div>
<div id="startup-loading" role="status" aria-live="polite" aria-busy="true">
<span class="startup-brand" aria-hidden="true">DBX</span>
<span class="startup-spinner" aria-hidden="true"></span>
<span>Loading…</span>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
+23 -12
View File
@@ -2,6 +2,10 @@
import { blockingDesktopAiRunsForUpdate } from "@/lib/ai/desktopAiRunRegistry";
import { setupUpdatePreparation, prepareUpdateWithDraftRecovery, isUpdatePreparationActive } from "@/lib/app/updatePreparation";
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineAsyncComponent, provide } from "vue";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import { checkStartupAuthentication, type StartupAuthentication } from "@/lib/startup/startupAuthentication";
import { markStartupPhase } from "@/lib/startup/startupTiming";
import { clearStartupPreloadRetry } from "@/lib/startup/startupPreloadRecovery";
import { useI18n } from "vue-i18n";
import { FileText } from "@lucide/vue";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -145,7 +149,7 @@ import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis";
import { countAvailableAgentDriverUpdates } from "@/lib/connection/agentDriverUpdateBadge";
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { apiUrl, webPath } from "@/lib/common/webPath";
import { webPath } from "@/lib/common/webPath";
import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard";
import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_MONO_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY, FONT_MONO_CSS_VAR } from "@/lib/app/appFonts";
import { DATA_GRID_TYPE_COLOR_KEYS, dataGridTypeColorCssVar, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
@@ -215,6 +219,7 @@ type AiAssistantHandle = {
type AuxiliarySearchSurface = "ai" | "history" | "sqlLibrary" | null;
const { t, locale: appLocale } = useI18n();
const startupProps = defineProps<{ startupAuthentication?: StartupAuthentication }>();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
@@ -362,9 +367,9 @@ const { mcpUpdateAvailable, refreshMcpUpdateStatus, handleMcpStatusChanged, appl
const drawDesktopWindowFrame = shouldDrawDesktopWindowFrame(isMacOS(), isDesktop, isWindows());
const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
let updateCheckTimer: ReturnType<typeof setInterval> | undefined;
const needsAuth = ref(!isDesktop);
const authenticated = ref(isDesktop);
const setupRequired = ref(false);
const needsAuth = ref(!isDesktop && (startupProps.startupAuthentication?.required ?? true));
const authenticated = ref(isDesktop || (startupProps.startupAuthentication?.authenticated ?? false));
const setupRequired = ref(!isDesktop && (startupProps.startupAuthentication?.setup_required ?? false));
const showConnectionDialog = ref(false);
const connectionDialogPrefill = ref<ConnectionDeepLinkDraft | null>(null);
@@ -3867,10 +3872,13 @@ async function initApp() {
console.log("[STARTUP] initApp begin");
const restoreOpenTabs = async () => {
await settingsStore.initEditorSettings();
markStartupPhase("settings-ready");
console.log(`[STARTUP] settingsStore.initEditorSettings: ${(performance.now() - t0).toFixed(0)}ms`);
await connectionStore.initFromDisk();
markStartupPhase("connections-ready");
console.log(`[STARTUP] connectionStore.initFromDisk: ${(performance.now() - t0).toFixed(0)}ms`);
await queryStore.initOpenTabs({ validConnectionIds: connectionStore.connections.map((connection) => connection.id) });
markStartupPhase("tabs-restored");
console.log(`[STARTUP] queryStore.initOpenTabs: ${(performance.now() - t0).toFixed(0)}ms`);
};
@@ -3975,6 +3983,8 @@ function runUpdateNotificationChecks() {
}
onMounted(async () => {
clearStartupPreloadRetry();
markStartupPhase("app-mounted");
console.log("[STARTUP] onMounted begin");
const mountStart = performance.now();
if (isDetachedWindowContext) {
@@ -4024,14 +4034,15 @@ onMounted(async () => {
true,
);
if (!isDesktop) {
try {
const res = await fetch(apiUrl("/api/auth/check"));
const data = await res.json();
needsAuth.value = data.required;
authenticated.value = data.authenticated;
setupRequired.value = data.setup_required;
} catch {
/* server unreachable */
if (!startupProps.startupAuthentication) {
try {
const data = await checkStartupAuthentication();
needsAuth.value = data.required;
authenticated.value = data.authenticated;
setupRequired.value = data.setup_required;
} catch {
/* server unreachable */
}
}
if (needsAuth.value && !authenticated.value) {
history.replaceState(null, "", webPath("/login"));
+59 -81
View File
@@ -1,15 +1,31 @@
<script setup lang="ts">
import { defineAsyncComponent, onMounted, ref, watch } from "vue";
import { defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef, watch } from "vue";
import { useI18n } from "vue-i18n";
import LoginPage from "@/components/auth/LoginPage.vue";
import SecurityMigrationWizard from "@/components/migration/SecurityMigrationWizard.vue";
import StartupLoading from "@/components/layout/StartupLoading.vue";
import { useMigrationStore } from "@/stores/migrationStore";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { apiUrl, webPath } from "@/lib/common/webPath";
import { webPath } from "@/lib/common/webPath";
import { loadSavedLocale } from "@/i18n";
import { checkStartupAuthentication, type StartupAuthentication } from "@/lib/startup/startupAuthentication";
import { markStartupPhase } from "@/lib/startup/startupTiming";
import { retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
// App setup installs listeners and instantiates business stores, so even its import
// is deferred until authentication and the security migration have completed.
const App = defineAsyncComponent(() => import("./App.vue"));
const startupAsyncOptions = {
loadingComponent: StartupLoading,
errorComponent: StartupLoading,
delay: 0,
suspensible: false,
onError(error: Error, _retry: () => void, fail: () => void) {
retryStartupAfterPreloadFailure(error);
fail();
},
};
const App = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("./App.vue") });
const LoginPage = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("@/components/auth/LoginPage.vue") });
const SecurityMigrationWizard = defineAsyncComponent({ ...startupAsyncOptions, loader: () => import("@/components/migration/SecurityMigrationWizard.vue") });
const props = defineProps<{ localeReady?: Promise<void> }>();
const { t } = useI18n();
const migration = useMigrationStore();
const { blocking } = migration;
@@ -17,33 +33,42 @@ const checkingAuth = ref(true);
const loginRequired = ref(false);
const setupRequired = ref(false);
const authFailed = ref(false);
const enteringApp = ref(false);
let launchTransitionTimer: number | undefined;
const startupAuthentication = shallowRef<StartupAuthentication>();
const checkingLocale = ref(Boolean(props.localeReady));
const localeFailed = ref(false);
let initialLocaleReady = props.localeReady;
let authRequest: AbortController | undefined;
watch(
() => migration.state.entered,
(entered) => {
if (entered) {
enteringApp.value = true;
}
},
);
watch(migration.blocking, (blocked) => {
if (!blocked) markStartupPhase("migration-ready");
});
function appReady() {
window.clearTimeout(launchTransitionTimer);
launchTransitionTimer = window.setTimeout(() => {
enteringApp.value = false;
}, 650);
async function initializeLocale() {
if (!initialLocaleReady && !localeFailed.value) return;
checkingLocale.value = true;
localeFailed.value = false;
try {
await (initialLocaleReady ?? loadSavedLocale());
markStartupPhase("locale-ready");
window.dispatchEvent(new Event("dbx:startup-ready"));
} catch {
localeFailed.value = true;
} finally {
initialLocaleReady = undefined;
checkingLocale.value = false;
}
}
async function initialize() {
authRequest?.abort();
const request = new AbortController();
authRequest = request;
checkingAuth.value = true;
authFailed.value = false;
try {
if (!isTauriRuntime()) {
const response = await fetch(apiUrl("/api/auth/check"), { credentials: "same-origin" });
if (!response.ok) throw new Error("AUTH_CHECK_FAILED");
const result = await response.json();
if (typeof result.required !== "boolean" || typeof result.authenticated !== "boolean") throw new Error("AUTH_CHECK_FAILED");
const result = await checkStartupAuthentication(request.signal);
if (request.signal.aborted) return;
startupAuthentication.value = result;
setupRequired.value = result.setup_required === true;
loginRequired.value = setupRequired.value || (result.required && !result.authenticated);
if (loginRequired.value) {
@@ -51,74 +76,27 @@ async function initialize() {
return;
}
}
markStartupPhase("auth-ready");
await migration.initialize();
} catch {
authFailed.value = true;
if (!request.signal.aborted) authFailed.value = true;
} finally {
checkingAuth.value = false;
if (!request.signal.aborted) checkingAuth.value = false;
}
}
async function authenticated() {
history.replaceState(null, "", webPath("/"));
await initialize();
}
onMounted(initialize);
onMounted(() => {
void initializeLocale();
void initialize();
});
onUnmounted(() => authRequest?.abort());
</script>
<template>
<div v-if="checkingAuth || authFailed" class="fixed inset-0 flex flex-col items-center justify-center gap-4 bg-background text-foreground" role="status">
<p>{{ t(authFailed ? "migration.authFailed" : "migration.checking") }}</p>
<button v-if="authFailed" class="rounded border px-4 py-2" @click="initialize">{{ t("migration.retry") }}</button>
</div>
<StartupLoading v-if="checkingLocale || localeFailed || checkingAuth || authFailed" :label="authFailed ? t('migration.authFailed') : !checkingLocale && !localeFailed ? t('migration.checking') : undefined" :error="localeFailed || authFailed" :retry="localeFailed ? initializeLocale : initialize" />
<LoginPage v-else-if="loginRequired" :setup-mode="setupRequired" @authenticated="authenticated" />
<SecurityMigrationWizard v-else-if="blocking" :store="migration" />
<div v-else class="relative min-h-screen">
<Suspense @resolve="appReady">
<App />
</Suspense>
<Transition name="startup-fade">
<div v-if="enteringApp" class="fixed inset-0 z-[1100] flex items-center justify-center bg-background text-foreground" role="status" aria-live="polite">
<div class="flex flex-col items-center gap-5 text-center">
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-2xl text-primary" aria-hidden="true">✓</span>
<div>
<p class="text-lg font-semibold">{{ t("migration.successTitle") }}</p>
<p class="mt-2 text-sm text-muted-foreground">{{ t("migration.launching") }}</p>
</div>
<span class="h-1.5 w-24 overflow-hidden rounded-full bg-primary/15" aria-hidden="true"><span class="startup-progress block h-full w-1/2 rounded-full bg-primary" /></span>
</div>
</div>
</Transition>
</div>
<App v-else :startup-authentication="startupAuthentication" />
</template>
<style scoped>
.startup-fade-enter-active,
.startup-fade-leave-active {
transition: opacity 280ms ease;
}
.startup-fade-enter-from,
.startup-fade-leave-to {
opacity: 0;
}
.startup-progress {
animation: startup-progress 1.2s ease-in-out infinite;
}
@keyframes startup-progress {
from {
transform: translateX(-100%);
}
to {
transform: translateX(200%);
}
}
@media (prefers-reduced-motion: reduce) {
.startup-progress {
animation: none;
transform: translateX(50%);
}
}
</style>
+212
View File
@@ -0,0 +1,212 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
desktop: vi.fn(),
appImported: vi.fn(),
reloadLocale: vi.fn(),
migrationStatus: vi.fn(),
migrationStart: vi.fn(),
migrationRetry: vi.fn(),
migrationCleanupBackups: vi.fn(),
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: mocks.desktop }));
vi.mock("@/lib/backend/api", () => mocks);
vi.mock("@/i18n", () => ({ loadSavedLocale: mocks.reloadLocale }));
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
const readyStatus = { state: "not_required", needsMigration: false };
let businessReady: ReturnType<typeof deferred<void>>;
let mountedApp: App | undefined;
let root: HTMLDivElement;
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
sessionStorage.clear();
mocks.desktop.mockReturnValue(true);
mocks.migrationStatus.mockResolvedValue(readyStatus);
mocks.reloadLocale.mockResolvedValue(undefined);
businessReady = deferred<void>();
root = document.createElement("div");
document.body.append(root);
vi.doMock("../App.vue", async () => {
mocks.appImported();
await businessReady.promise;
return {
__esModule: true,
default: defineComponent({
props: ["startupAuthentication"],
setup: (props) => () => h("main", { "data-business-app": "" }, JSON.stringify(props.startupAuthentication ?? null)),
}),
};
});
vi.doMock("@/components/auth/LoginPage.vue", () => ({
__esModule: true,
default: defineComponent({
props: ["setupMode"],
emits: ["authenticated"],
setup:
(props, { emit }) =>
() =>
h("button", { "data-login": "", onClick: () => emit("authenticated") }, props.setupMode ? "setup" : "login"),
}),
}));
vi.doMock("@/components/migration/SecurityMigrationWizard.vue", () => ({
__esModule: true,
default: defineComponent({
props: ["store"],
setup: (props) => () =>
h(
"button",
{
"data-migration": "",
onClick: async () => {
await props.store.start();
props.store.enter();
},
},
"migrate",
),
}),
}));
});
afterEach(() => {
mountedApp?.unmount();
mountedApp = undefined;
root.remove();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
async function mountGate(localeReady?: Promise<void>) {
const { default: Gate } = await import("../StartupGate.vue");
mountedApp = createApp(Gate, { localeReady });
mountedApp.config.errorHandler = vi.fn();
mountedApp.mount(root);
}
describe("startup boundary", () => {
it("checks migration while the locale loads and keeps feedback until the app module resolves", async () => {
const locale = deferred<void>();
await mountGate(locale.promise);
await vi.waitFor(() => expect(mocks.migrationStatus).toHaveBeenCalledTimes(1));
expect(mocks.appImported).not.toHaveBeenCalled();
expect(root.querySelector("[data-startup-loading]")).not.toBeNull();
locale.resolve();
await vi.waitFor(() => expect(mocks.appImported).toHaveBeenCalledTimes(1));
expect(root.querySelector("[data-startup-loading]")).not.toBeNull();
expect(root.querySelector("main")).toBeNull();
businessReady.resolve();
await vi.waitFor(() => expect(root.querySelector("[data-business-app]")).not.toBeNull());
expect(root.querySelector("[data-startup-loading]")).toBeNull();
});
it("passes the checked Web authentication state without an extra request", async () => {
mocks.desktop.mockReturnValue(false);
const authentication = { required: true, authenticated: true, setup_required: false };
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => authentication });
vi.stubGlobal("fetch", fetchMock);
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(JSON.parse(root.querySelector("main")!.textContent!)).toEqual(authentication);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it.each([false, true])("does not inspect protected data until login/setup completes (setup=%s)", async (setup) => {
mocks.desktop.mockReturnValue(false);
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: true, authenticated: false, setup_required: setup }) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: true, authenticated: true }) });
vi.stubGlobal("fetch", fetchMock);
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector("[data-login]")).not.toBeNull());
expect(root.querySelector("[data-login]")!.textContent).toBe(setup ? "setup" : "login");
expect(mocks.migrationStatus).not.toHaveBeenCalled();
expect(mocks.appImported).not.toHaveBeenCalled();
(root.querySelector("[data-login]") as HTMLButtonElement).click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(mocks.migrationStatus).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not load the business app until a required migration is completed and entered", async () => {
mocks.migrationStatus.mockResolvedValueOnce({ state: "pending", needsMigration: true }).mockResolvedValue(readyStatus);
mocks.migrationStart.mockResolvedValue({ state: "succeeded" });
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector("[data-migration]")).not.toBeNull());
expect(mocks.appImported).not.toHaveBeenCalled();
(root.querySelector("[data-migration]") as HTMLButtonElement).click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(mocks.migrationStart).toHaveBeenCalledTimes(1);
});
it("fails closed on malformed auth and allows retry", async () => {
mocks.desktop.mockReturnValue(false);
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => null })
.mockResolvedValueOnce({ ok: true, json: async () => ({ required: false, authenticated: false }) });
vi.stubGlobal("fetch", fetchMock);
businessReady.resolve();
await mountGate();
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
expect(mocks.migrationStatus).not.toHaveBeenCalled();
expect(mocks.appImported).not.toHaveBeenCalled();
root.querySelector("button")!.click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
});
it("retries a failed locale load without rerunning successful security checks", async () => {
const locale = deferred<void>();
businessReady.resolve();
await mountGate(locale.promise);
locale.reject(new Error("locale download failed"));
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
expect(mocks.appImported).not.toHaveBeenCalled();
root.querySelector("button")!.click();
await vi.waitFor(() => expect(root.querySelector("main")).not.toBeNull());
expect(mocks.reloadLocale).toHaveBeenCalledTimes(1);
expect(mocks.migrationStatus).toHaveBeenCalledTimes(1);
});
it("shows a safe retry state when the app module fails to load", async () => {
await mountGate();
await vi.waitFor(() => expect(mocks.appImported).toHaveBeenCalledTimes(1));
businessReady.reject(new Error("private module URL"));
await vi.waitFor(() => expect(root.querySelector('[role="alert"]')).not.toBeNull());
expect(root.textContent).toContain("startup.loadFailed");
expect(root.textContent).not.toContain("private module URL");
expect(root.querySelector("button")).not.toBeNull();
});
it("cancels pending authentication when the gate unmounts", async () => {
mocks.desktop.mockReturnValue(false);
const fetchMock = vi.fn().mockReturnValue(new Promise(() => {}));
vi.stubGlobal("fetch", fetchMock);
await mountGate();
await nextTick();
const signal = fetchMock.mock.calls[0]?.[1].signal as AbortSignal;
mountedApp!.unmount();
mountedApp = undefined;
expect(signal.aborted).toBe(true);
expect(mocks.migrationStatus).not.toHaveBeenCalled();
});
});
@@ -8,6 +8,7 @@ import { useUpdateBlocker } from "@/lib/app/updatePreparation";
import { useResultViewUpdateTiming } from "@/composables/useResultViewUpdateTiming";
import { computed, nextTick, onMounted, onUnmounted, onActivated, onDeactivated, ref, shallowRef, toRaw, useSlots, watch, defineAsyncComponent, type Component, type CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { RecycleScroller } from "vue-virtual-scroller";
import {
ArrowUp,
ArrowDown,
@@ -61,7 +61,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { Switch } from "@/components/ui/switch";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
import QueryErrorActions from "@/components/common/QueryErrorActions.vue";
@@ -93,6 +92,7 @@ function preloadDataGridComponent() {
void loadDataGridComponent();
}
const QueryEditor = defineAsyncComponent({ loader: () => import("@/components/editor/QueryEditor.vue"), loadingComponent: QueryLoadingState, delay: 0 });
const DataGrid = defineAsyncComponent(loadDataGridComponent);
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
const props = defineProps<{ label?: string; error?: unknown; retry?: () => void }>();
const { t } = useI18n();
function retry() {
if (props.retry) props.retry();
else window.location.reload();
}
</script>
<template>
<div data-startup-loading class="fixed inset-0 z-[1100] flex flex-col items-center justify-center gap-4 bg-background text-foreground" :role="error ? 'alert' : 'status'" aria-live="polite" :aria-busy="!error">
<span class="text-xl font-semibold tracking-tight" aria-hidden="true">DBX</span>
<span v-if="!error" class="startup-spinner" aria-hidden="true" />
<p class="text-sm text-muted-foreground">{{ label || t(error ? "startup.loadFailed" : "startup.loading") }}</p>
<button v-if="error" class="rounded border border-border px-4 py-2 text-sm hover:bg-muted focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring" @click="retry">{{ t("common.retry") }}</button>
</div>
</template>
<style scoped>
.startup-spinner {
width: 22px;
height: 22px;
border: 2px solid var(--muted);
border-top-color: var(--muted-foreground);
border-radius: 50%;
animation: startup-spin 0.8s linear infinite;
}
@keyframes startup-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.startup-spinner {
animation: none;
}
}
</style>
+4
View File
@@ -2932,6 +2932,10 @@ export default {
legacy: "Backend returned an error.",
unknown: "An unexpected backend error occurred.",
},
startup: {
loading: "Starting DBX…",
loadFailed: "Unable to load DBX. Please try again.",
},
common: {
fileNotFound: "File does not exist: {path}",
language: "Language",
+4
View File
@@ -2844,6 +2844,10 @@ export default withEnglishFallback({
legacy: "后端返回了错误。",
unknown: "发生了未预期的后端错误。",
},
startup: {
loading: "正在启动 DBX…",
loadFailed: "无法加载 DBX,请重试。",
},
common: {
fileNotFound: "文件不存在:{path}",
language: "语言",
@@ -0,0 +1,34 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { checkStartupAuthentication } from "@/lib/startup/startupAuthentication";
afterEach(() => vi.unstubAllGlobals());
describe("startup authentication", () => {
it("preserves authenticated, disabled-auth and setup-required states", async () => {
for (const state of [
{ required: true, authenticated: true, setup_required: false },
{ required: false, authenticated: false, setup_required: false },
{ required: true, authenticated: false, setup_required: true },
]) {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => state });
vi.stubGlobal("fetch", fetchMock);
expect(await checkStartupAuthentication()).toEqual(state);
expect(fetchMock).toHaveBeenCalledWith("/api/auth/check", expect.objectContaining({ credentials: "same-origin" }));
}
});
it.each([null, [], "invalid", {}, { required: "false", authenticated: true }, { required: false, authenticated: "true" }])("fails closed for malformed authentication data: %j", async (state) => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => state }));
await expect(checkStartupAuthentication()).rejects.toThrow("AUTH_CHECK_FAILED");
});
it("fails closed on unsuccessful responses and forwards cancellation", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false });
vi.stubGlobal("fetch", fetchMock);
const controller = new AbortController();
await expect(checkStartupAuthentication(controller.signal)).rejects.toThrow("AUTH_CHECK_FAILED");
expect(fetchMock.mock.calls[0]?.[1].signal).toBe(controller.signal);
});
});
@@ -0,0 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { markStartupPhase } from "@/lib/startup/startupTiming";
afterEach(() => vi.unstubAllGlobals());
describe("startup phase marks", () => {
it("records each phase once without confusing gate mount with app readiness", () => {
const entries = new Set<string>();
const mark = vi.fn((name: string) => entries.add(name));
vi.stubGlobal("performance", { mark, getEntriesByName: (name: string) => (entries.has(name) ? [{}] : []) });
markStartupPhase("gate-mounted");
markStartupPhase("gate-mounted");
expect(mark).toHaveBeenCalledTimes(1);
expect(entries.has("dbx:startup:app-mounted")).toBe(false);
markStartupPhase("app-mounted");
expect(mark).toHaveBeenCalledTimes(2);
});
it("does not block startup when performance marks are unavailable", () => {
vi.stubGlobal("performance", {});
expect(() => markStartupPhase("bootstrap")).not.toThrow();
});
});
+1 -5
View File
@@ -27,11 +27,7 @@ interface InitializeOpenTabsOptions {
}
export async function initializeOpenTabs({ initializeOptionalState, restoreOpenTabs, onOptionalStateError }: InitializeOpenTabsOptions): Promise<void> {
try {
await initializeOptionalState();
} catch (error) {
onOptionalStateError(error);
}
void Promise.resolve().then(initializeOptionalState).catch(onOptionalStateError);
await restoreOpenTabs();
}
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
settingsImported: vi.fn(),
migrationStatus: vi.fn(),
listInstalledAgents: vi.fn(),
installAgent: vi.fn(),
upgradeAllAgents: vi.fn(),
reinstallJre: vi.fn(),
}));
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@/lib/backend/tauri", () => mocks);
vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn() }));
const settings = { editorSettings: { updateDownloadSource: "github" } };
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
settings.editorSettings.updateDownloadSource = "github";
vi.doMock("@/stores/settingsStore", () => {
mocks.settingsImported();
return { useSettingsStore: () => settings };
});
});
describe("startup API dependency boundary", () => {
it("does not import the business settings store for migration preflight", async () => {
mocks.migrationStatus.mockResolvedValue({ state: "not_required" });
const api = await import("../api");
await expect(api.migrationStatus()).resolves.toEqual({ state: "not_required" });
expect(mocks.settingsImported).not.toHaveBeenCalled();
});
it("continues using the current configured source for agent operations", async () => {
const api = await import("../api");
await api.listInstalledAgents();
expect(mocks.listInstalledAgents).toHaveBeenCalledWith("github");
settings.editorSettings.updateDownloadSource = "cloudflare";
await api.installAgent("mysql", "install-1");
await api.upgradeAllAgents("upgrade-1");
await api.reinstallJre("java-21", "jre-1");
expect(mocks.installAgent).toHaveBeenCalledWith("mysql", "cloudflare", "install-1");
expect(mocks.upgradeAllAgents).toHaveBeenCalledWith("cloudflare", "upgrade-1");
expect(mocks.reinstallJre).toHaveBeenCalledWith("java-21", "cloudflare", "jre-1");
expect(mocks.settingsImported).toHaveBeenCalledTimes(1);
});
});
+4 -1
View File
@@ -1,7 +1,6 @@
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import type * as TauriModule from "@/lib/backend/tauri";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { useSettingsStore } from "@/stores/settingsStore";
import type { AiConfigItem } from "@/types/ai";
// ---------------------------------------------------------------------------
@@ -135,6 +134,7 @@ export const uninstallJdbcPlugin = forward("uninstallJdbcPlugin");
export const listInstalledAgentsLocal = forward("listInstalledAgentsLocal");
export async function listInstalledAgents() {
const backend = await getBackend();
const { useSettingsStore } = await import("@/stores/settingsStore");
return backend.listInstalledAgents(useSettingsStore().editorSettings.updateDownloadSource);
}
export const isAgentInstalled = forward("isAgentInstalled");
@@ -145,10 +145,12 @@ export const stopDriverRuntime = forward("stopDriverRuntime");
export const restartDriverRuntime = forward("restartDriverRuntime");
export async function installAgent(dbType: string, operationId?: string) {
const backend = await getBackend();
const { useSettingsStore } = await import("@/stores/settingsStore");
return backend.installAgent(dbType, useSettingsStore().editorSettings.updateDownloadSource, operationId);
}
export async function upgradeAllAgents(operationId?: string) {
const backend = await getBackend();
const { useSettingsStore } = await import("@/stores/settingsStore");
return backend.upgradeAllAgents(useSettingsStore().editorSettings.updateDownloadSource, operationId);
}
export const cancelAgentInstall = forward("cancelAgentInstall");
@@ -168,6 +170,7 @@ export const importAgentDriver = forward("importAgentDriver");
export const importAgentJar = importAgentDriver;
export async function reinstallJre(jreKey?: string, operationId?: string) {
const backend = await getBackend();
const { useSettingsStore } = await import("@/stores/settingsStore");
return backend.reinstallJre(jreKey, useSettingsStore().editorSettings.updateDownloadSource, operationId);
}
export const uninstallJre = forward("uninstallJre");
@@ -0,0 +1,21 @@
import { apiUrl } from "@/lib/common/webPath";
export interface StartupAuthentication {
required: boolean;
authenticated: boolean;
setup_required: boolean;
}
export async function checkStartupAuthentication(signal?: AbortSignal): Promise<StartupAuthentication> {
const response = await fetch(apiUrl("/api/auth/check"), { credentials: "same-origin", signal });
if (!response.ok) throw new Error("AUTH_CHECK_FAILED");
const result: unknown = await response.json();
if (!result || typeof result !== "object" || !("required" in result) || !("authenticated" in result) || typeof result.required !== "boolean" || typeof result.authenticated !== "boolean") {
throw new Error("AUTH_CHECK_FAILED");
}
return {
required: result.required,
authenticated: result.authenticated,
setup_required: "setup_required" in result && result.setup_required === true,
};
}
@@ -0,0 +1,8 @@
export type StartupPhase = "bootstrap" | "gate-mounted" | "locale-ready" | "auth-ready" | "migration-ready" | "app-mounted" | "settings-ready" | "connections-ready" | "tabs-restored";
export function markStartupPhase(phase: StartupPhase): void {
const name = `dbx:startup:${phase}`;
const clock = globalThis.performance;
if (typeof clock?.mark !== "function" || typeof clock.getEntriesByName !== "function") return;
if (!clock.getEntriesByName(name, "mark").length) clock.mark(name);
}
+7 -10
View File
@@ -1,10 +1,9 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import VueVirtualScroller from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import "./styles/globals.css";
import { installDebugLogCapture } from "@/lib/backend/debugLog";
import { clearStartupPreloadRetry, retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
import { retryStartupAfterPreloadFailure } from "@/lib/startup/startupPreloadRecovery";
import { markStartupPhase } from "@/lib/startup/startupTiming";
import { applyLegacyWebViewClass } from "@/lib/ui/legacyWebView";
function startupErrorMessage(error: unknown): string {
@@ -76,19 +75,17 @@ function installGlobalInputAttrs() {
}
async function bootstrap() {
markStartupPhase("bootstrap");
console.log("[STARTUP] frontend bootstrap begin");
const [{ default: i18n, loadSavedLocale }, { default: App }] = await Promise.all([import("./i18n"), import("./StartupGate.vue")]);
console.log("[STARTUP] frontend modules loaded");
await loadSavedLocale();
console.log("[STARTUP] locale ready");
const app = createApp(App);
const localeReady = loadSavedLocale();
const app = createApp(App, { localeReady });
app.use(createPinia());
app.use(i18n);
app.use(VueVirtualScroller);
app.mount("#root");
clearStartupPreloadRetry();
window.dispatchEvent(new Event("dbx:startup-ready"));
markStartupPhase("gate-mounted");
void localeReady.then(() => window.dispatchEvent(new Event("dbx:startup-ready"))).catch(() => {});
console.log("[STARTUP] vue mounted");
installGlobalInputAttrs();
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSettingsStore } from "../settingsStore";
const backend = vi.hoisted(() => ({
loadAiConfigs: vi.fn(),
loadAiChatSelection: vi.fn(),
loadAiConfig: vi.fn(),
loadAiProviderConfigs: vi.fn(),
saveAiChatSelection: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => backend);
beforeEach(() => {
vi.resetAllMocks();
localStorage.clear();
setActivePinia(createPinia());
backend.loadAiChatSelection.mockResolvedValue(null);
backend.loadAiConfig.mockResolvedValue(null);
backend.loadAiProviderConfigs.mockResolvedValue(null);
});
describe("AI startup initialization", () => {
it("shares pending initialization between startup and the AI panel", async () => {
let resolveConfigs!: (configs: []) => void;
backend.loadAiConfigs.mockImplementation(
() =>
new Promise<[]>((resolve) => {
resolveConfigs = resolve;
}),
);
const store = useSettingsStore();
const first = store.initAiConfigs();
const second = store.initAiConfigs();
expect(backend.loadAiConfigs).toHaveBeenCalledTimes(1);
expect(store.isAiConfigLoaded).toBe(false);
resolveConfigs([]);
await Promise.all([first, second]);
expect(store.isAiConfigLoaded).toBe(true);
expect(backend.loadAiChatSelection).toHaveBeenCalledTimes(1);
await store.initAiConfigs();
expect(backend.loadAiConfigs).toHaveBeenCalledTimes(1);
});
it("permits retry after a shared initialization failure", async () => {
backend.loadAiConfigs.mockRejectedValueOnce(new Error("unavailable")).mockResolvedValue([]);
const store = useSettingsStore();
const pending = [store.initAiConfigs(), store.initAiConfigs()];
const results = await Promise.allSettled(pending);
expect(results.every((result) => result.status === "rejected")).toBe(true);
expect(store.isAiConfigLoaded).toBe(false);
await store.initAiConfigs();
expect(backend.loadAiConfigs).toHaveBeenCalledTimes(2);
expect(store.isAiConfigLoaded).toBe(true);
});
});
+10 -1
View File
@@ -25,7 +25,7 @@ import { REDIS_DATABASE_DISPLAY_LIMIT_DEFAULT, REDIS_DATABASE_DISPLAY_LIMIT_MIN,
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
import { normalizeSidebarCopyTableNameSeparator } from "@/lib/sidebar/sidebarTableNameCopy";
import type { SidebarActivation } from "@/lib/sidebar/treeNodeClick";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlSnippetTemplates";
import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
import { canonicalSqlShortcutSql, DEFAULT_SQL_SHORTCUTS, deriveSqlShortcutDatabaseTypes, mergeDefaultSqlShortcuts, normalizeSqlShortcutDatabaseTypes, normalizeSqlShortcutKind, normalizeSqlShortcutLimit, normalizeSqlShortcutSqlByDatabaseType } from "@/lib/sql/sqlShortcutActions";
import { normalizeSqlVariableSyntaxOverrides, type SqlVariableSyntaxOverrides } from "@/lib/sql/sqlVariableSyntax";
@@ -1963,6 +1963,7 @@ export const useSettingsStore = defineStore("settings", () => {
const isMcpGlobalPolicyLoaded = ref(false);
const isEditorSettingsLoaded = ref(false);
let initEditorSettingsPromise: Promise<void> | null = null;
let initAiConfigsPromise: Promise<void> | null = null;
let pendingEditorSettingsPatches: Partial<EditorSettings>[] = [];
let editorSettingsOperationQueue: Promise<void> | null = null;
let editorSettingsPatchRevision = 0;
@@ -2168,7 +2169,15 @@ export const useSettingsStore = defineStore("settings", () => {
async function initAiConfigs(): Promise<void> {
if (isAiConfigLoaded.value) return;
if (!initAiConfigsPromise) {
initAiConfigsPromise = loadAiConfigurationState().finally(() => {
initAiConfigsPromise = null;
});
}
await initAiConfigsPromise;
}
async function loadAiConfigurationState(): Promise<void> {
// 尝试加载新格式
const newConfigs = await api.loadAiConfigs();
+1
View File
@@ -39,6 +39,7 @@
"bench:redis-key-search": "node scripts/bench/redis-key-search.mjs",
"bench:mongodb-count": "node scripts/bench/mongodb-count.mjs",
"bench:sidebar-large-schema": "vitest bench apps/desktop/src/lib/sidebar/sidebarLargeSchema.bench.ts",
"bench:startup": "node --import tsx scripts/bench/startup-bundle.mjs",
"bench:table-import": "cargo run -p dbx-core --no-default-features --example table_import_bench --release --",
"lint": "oxlint --vue-plugin apps/desktop/src",
"fmt": "oxfmt \"apps/desktop/src/**/*.{ts,vue}\"",
+66 -5
View File
@@ -7,6 +7,10 @@ const appSource = readFileSync("apps/desktop/src/App.vue", "utf8");
test("web startup reports optional initialization failure and continues restoring persisted state", async () => {
const events: string[] = [];
let reportError!: () => void;
const errorReported = new Promise<void>((resolve) => {
reportError = resolve;
});
await initializeOpenTabs({
initializeOptionalState: async () => {
@@ -18,10 +22,18 @@ test("web startup reports optional initialization failure and continues restorin
events.push("initialize-connections");
events.push("restore-tabs");
},
onOptionalStateError: () => events.push("report-ai-config-error"),
onOptionalStateError: () => {
events.push("report-ai-config-error");
reportError();
},
});
assert.deepEqual(events, ["initialize-ai-configs", "report-ai-config-error", "initialize-editor-settings", "initialize-connections", "restore-tabs"]);
await errorReported;
assert.deepEqual(
events.filter((event) => !event.includes("ai-config")),
["initialize-editor-settings", "initialize-connections", "restore-tabs"],
);
assert.ok(events.includes("report-ai-config-error"));
});
test("shared startup initializes optional state once and propagates required restoration failure", async () => {
@@ -64,10 +76,11 @@ test("web startup uses the guarded initializer behind the existing authenticatio
assert.ok(mountedStart >= 0 && mountedEnd > mountedStart);
const mountedSource = appSource.slice(mountedStart, mountedEnd);
const authCheck = mountedSource.indexOf('fetch(apiUrl("/api/auth/check"))');
const cachedAuth = mountedSource.indexOf("if (!startupProps.startupAuthentication)");
const authCheck = mountedSource.indexOf("await checkStartupAuthentication()", cachedAuth);
const loginRedirect = mountedSource.indexOf('history.replaceState(null, "", webPath("/login"))', authCheck);
const authenticatedInitialization = mountedSource.indexOf("if (!setupRequired.value && (!needsAuth.value || authenticated.value)) void initApp()", loginRedirect);
assert.ok(authCheck >= 0);
assert.ok(cachedAuth >= 0 && authCheck > cachedAuth);
assert.ok(loginRedirect > authCheck);
assert.ok(authenticatedInitialization > loginRedirect);
});
@@ -96,7 +109,55 @@ test("desktop SQL file opening survives unrelated initialization failure and fol
});
await fileOpen;
assert.deepEqual(events, ["initialize-ai-configs", "ignore-ai-config-error", "initialize-editor-settings", "initialize-connections", "restore-tabs", "read-file", "open-tab"]);
await Promise.resolve();
assert.deepEqual(
events.filter((event) => !event.includes("ai-config")),
["initialize-editor-settings", "initialize-connections", "restore-tabs", "read-file", "open-tab"],
);
assert.ok(events.includes("ignore-ai-config-error"));
});
test("pending optional AI initialization does not hold tab restoration or external file opening", async () => {
let rejectOptional!: (error: Error) => void;
const optional = new Promise<void>((_resolve, reject) => {
rejectOptional = reject;
});
const errors: unknown[] = [];
const barrier = createOpenTabsRestorationBarrier();
const events: string[] = [];
const fileOpen = barrier.settled.then(() => events.push("open-file"));
await initializeDesktopOpenTabs({
barrier,
initializeOptionalState: () => optional,
restoreOpenTabs: async () => {
events.push("restore-tabs");
},
onOptionalStateError: (error) => errors.push(error),
});
await fileOpen;
assert.deepEqual(events, ["restore-tabs", "open-file"]);
const error = new Error("AI config unavailable");
rejectOptional(error);
await optional.catch(() => {});
await Promise.resolve();
assert.deepEqual(errors, [error]);
});
test("a synchronous optional initialization failure does not block required restoration", async () => {
const events: string[] = [];
await initializeOpenTabs({
initializeOptionalState: () => {
throw new Error("optional failure");
},
restoreOpenTabs: async () => {
events.push("restored");
},
onOptionalStateError: () => {
events.push("reported");
},
});
await Promise.resolve();
assert.deepEqual(events, ["restored", "reported"]);
});
test("desktop SQL file opening is released when persisted tab restoration rejects", async () => {
+28
View File
@@ -0,0 +1,28 @@
# Startup dependency benchmark
Run `pnpm bench:startup` from the repository root. The benchmark uses the current
production Vite configuration, checks generated connection descriptors, and builds
in memory without replacing `dist` or writing generated source files.
The JSON report lists cumulative static JavaScript dependencies for the entry,
startup gate, and main application, with English and Simplified Chinese variants.
Later phases include the earlier phases; do not add their byte counts together.
Gzip sizes are calculated per chunk, not measured HTTP transfer sizes. CSS, images,
runtime-selected backend transports, and other dynamic imports are not included.
The startup gate must not statically depend on the settings/connection/query stores
or the SQL editor. Check the `includes` fields to detect a broken lazy boundary.
Loading a previously saved SQL tab will still load its editor on demand.
Runtime phase markers are available through
`performance.getEntriesByType("mark").filter(entry => entry.name.startsWith("dbx:startup:"))`.
They distinguish gate mounting, locale/auth/migration completion, app mounting,
settings, connections, and restored tabs. App mounting is not editor interactivity;
the legacy `dbx:startup-ready` input-guard event is not a main-screen readiness metric.
Validate desktop and Web, returning users and first-run migration, login/setup,
slow or failed locale/module loading, optional AI failures, and restored/external
SQL tabs. Keep authentication before migration inspection, and editor settings
before connections before tab restoration. Bundle reductions are not cold-start
timing claims: native setup, storage, WebView parsing and rendering still need
separate measurement.
+57
View File
@@ -0,0 +1,57 @@
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
import { build } from "vite";
import configFactory from "../../apps/desktop/vite.config.ts";
const repository = fileURLToPath(new URL("../../", import.meta.url));
execFileSync(process.execPath, ["scripts/sync-connection-types.mjs", "--check"], { cwd: repository, stdio: ["ignore", "pipe", "inherit"] });
const config = await configFactory({ command: "build", mode: "production" });
const result = await build({
...config,
configFile: false,
logLevel: "error",
plugins: config.plugins.filter((plugin) => plugin?.name !== "dbx-connection-types"),
build: { ...config.build, write: false, emptyOutDir: false, reportCompressedSize: false },
});
const chunks = (Array.isArray(result) ? result : [result]).flatMap((bundle) => bundle.output).filter((output) => output.type === "chunk");
const byName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]));
const sourceChunk = (suffix) => chunks.find((chunk) => Object.keys(chunk.modules).some((id) => id.endsWith(suffix)));
const entry = chunks.find((chunk) => chunk.isEntry && chunk.facadeModuleId?.endsWith("/index.html"));
const gate = sourceChunk("/src/StartupGate.vue");
const locale = sourceChunk("/src/i18n/index.ts");
const app = sourceChunk("/src/App.vue");
const chinese = sourceChunk("/src/i18n/locales/zh-CN.ts");
if (!entry || !gate || !locale || !app || !chinese) throw new Error("Startup bundle entrypoints were not found");
function summarize(phase, seeds) {
const visited = new Set();
function visit(name) {
if (visited.has(name) || !byName.has(name)) return;
visited.add(name);
byName.get(name).imports.forEach(visit);
}
seeds.forEach((chunk) => visit(chunk.fileName));
const dependencies = [...visited].map((name) => byName.get(name));
const files = dependencies.map((chunk) => ({ file: chunk.fileName, bytes: Buffer.byteLength(chunk.code), gzipBytes: gzipSync(chunk.code).length }));
const modules = dependencies.flatMap((chunk) => Object.keys(chunk.modules));
return {
phase,
jsChunks: files.length,
bytes: files.reduce((total, file) => total + file.bytes, 0),
gzipBytes: files.reduce((total, file) => total + file.gzipBytes, 0),
largestFiles: files.sort((left, right) => right.bytes - left.bytes).slice(0, 8),
includes: Object.fromEntries(["settingsStore.ts", "connectionStore.ts", "queryStore.ts", "QueryEditor.vue", "/node_modules/@codemirror/"].map((pattern) => [pattern, modules.some((id) => id.includes(pattern))])),
};
}
console.log(
JSON.stringify(
{
kind: "static-js-dependencies-not-startup-timing",
phases: [summarize("entry", [entry]), summarize("startup-gate-en", [entry, gate, locale]), summarize("startup-gate-zh-CN", [entry, gate, locale, chinese]), summarize("app-en", [entry, gate, locale, app]), summarize("app-zh-CN", [entry, gate, locale, app, chinese])],
},
null,
2,
),
);