Fix premature settings evaluation at startup

This commit is contained in:
vorotamoroz
2026-08-25 13:57:04 +00:00
parent a5e1acb960
commit 537e0e42c3
6 changed files with 232 additions and 62 deletions
@@ -8,8 +8,9 @@ import { openObsidianSettings } from "@/common/obsidianSettings.ts";
export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
settingTab!: ObsidianLiveSyncSettingTab;
_everyOnloadStart(): Promise<boolean> {
_everyOnloadAfterLoadSettings(): Promise<boolean> {
this.settingTab = new ObsidianLiveSyncSettingTab(this.app, this.plugin);
this.settingTab.reloadAllSettings(true);
this.plugin.addSettingTab(this.settingTab);
eventHub.onEvent(EVENT_REQUEST_OPEN_SETTINGS, () => this.openSetting());
@@ -24,6 +25,6 @@ export class ModuleObsidianSettingDialogue extends AbstractObsidianModule {
return `${"appId" in this.app ? this.app.appId : ""}`;
}
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this));
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
}
}
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const settingTabState = vi.hoisted(() => ({
callOrder: [] as string[],
reloadAllSettings: vi.fn<(skipUpdate?: boolean) => void>(),
}));
const eventHubState = vi.hoisted(() => ({
onEvent: vi.fn(),
}));
vi.mock("./SettingDialogue/ObsidianLiveSyncSettingTab.ts", () => ({
ObsidianLiveSyncSettingTab: class ObsidianLiveSyncSettingTab {
reloadAllSettings(skipUpdate?: boolean) {
settingTabState.callOrder.push(`reload:${String(skipUpdate)}`);
settingTabState.reloadAllSettings(skipUpdate);
}
},
}));
vi.mock("@/common/events.ts", () => ({
EVENT_REQUEST_OPEN_SETTINGS: "request-open-settings",
eventHub: eventHubState,
}));
import { ModuleObsidianSettingDialogue } from "./ModuleObsidianSettingTab.ts";
function createModuleHarness() {
let initialisationHandler: (() => Promise<boolean>) | undefined;
let settingsLoadedHandler: (() => Promise<boolean>) | undefined;
const plugin = {
app: {},
addSettingTab: vi.fn(() => settingTabState.callOrder.push("add-setting-tab")),
};
const services = {
appLifecycle: {
onInitialise: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
initialisationHandler = handler;
}),
},
onSettingLoaded: {
addHandler: vi.fn((handler: () => Promise<boolean>) => {
settingsLoadedHandler = handler;
}),
},
},
};
const module = Object.assign(Object.create(ModuleObsidianSettingDialogue.prototype), {
plugin,
core: { services },
}) as ModuleObsidianSettingDialogue;
module.onBindFunction(module.core as never, services as never);
return {
initialisationHandler: () => initialisationHandler,
module,
plugin,
services,
settingsLoadedHandler: () => settingsLoadedHandler,
};
}
describe("ModuleObsidianSettingDialogue startup lifecycle", () => {
beforeEach(() => {
settingTabState.callOrder.length = 0;
settingTabState.reloadAllSettings.mockClear();
eventHubState.onEvent.mockClear();
});
it("registers the setting tab after persisted settings have loaded", () => {
const { initialisationHandler, services, settingsLoadedHandler } = createModuleHarness();
expect(services.appLifecycle.onInitialise.addHandler).not.toHaveBeenCalled();
expect(services.appLifecycle.onSettingLoaded.addHandler).toHaveBeenCalledOnce();
expect(initialisationHandler()).toBeUndefined();
expect(settingsLoadedHandler()).toBeTypeOf("function");
});
it("seeds the setting editor without requesting a render before registration", async () => {
const { initialisationHandler, settingsLoadedHandler } = createModuleHarness();
const handler = settingsLoadedHandler() ?? initialisationHandler();
expect(handler).toBeTypeOf("function");
await handler!();
expect(settingTabState.reloadAllSettings).toHaveBeenCalledWith(true);
expect(settingTabState.callOrder).toEqual(["reload:true", "add-setting-tab"]);
});
});
@@ -145,22 +145,36 @@ function findPage(tab: ObsidianLiveSyncSettingTab, name: string): SettingDefinit
return page;
}
function createSettingsTab(): ObsidianLiveSyncSettingTab {
const plugin = {
app: {},
core: {
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
confirm: {
askInPopup: vi.fn(),
type SettingsTabOptions = {
activeReplicatorGetter?: () => { syncStatus: "CONNECTED" | "PAUSED" } | undefined;
replicationStatus?: "CLOSED" | "CONNECTED" | "PAUSED";
};
function createSettingsTab(options: SettingsTabOptions = {}): ObsidianLiveSyncSettingTab {
const core = {
settings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
confirm: {
askInPopup: vi.fn(),
},
services: {
setting: {
getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined),
},
services: {
setting: {
getDeviceAndVaultName: vi.fn(() => ""),
saveSettingData: vi.fn(async () => undefined),
replicator: {
replicationStatics: {
value: { syncStatus: options.replicationStatus ?? "CLOSED" },
},
},
},
};
Object.defineProperty(core, "replicator", {
get: options.activeReplicatorGetter ?? (() => undefined),
});
const plugin = {
app: {},
core,
};
const tab = new ObsidianLiveSyncSettingTab({} as never, plugin as never);
Object.assign(tab, {
_editingSettings: { ...DEFAULT_SETTINGS, useAdvancedMode: true },
@@ -181,8 +195,27 @@ beforeEach(() => {
});
describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
it("keeps Quick Setup first while synchronisation is inactive and separates synchronisation pages from it", () => {
it("builds definitions before database readiness without requesting the active replicator", () => {
const activeReplicatorGetter = vi.fn(() => {
throw new Error("The active replicator is not ready");
});
const tab = createSettingsTab({ activeReplicatorGetter });
expect(() => tab.getSettingDefinitions()).not.toThrow();
expect(activeReplicatorGetter).not.toHaveBeenCalled();
});
it("keeps Quick Setup first while LiveSync is not configured, regardless of transient replication status", () => {
const tab = createSettingsTab({ replicationStatus: "CONNECTED" });
tab.editingSettings.isConfigured = false;
const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions[0]?.heading).toBe("🧙‍♂️ Quick Setup");
});
it("keeps Quick Setup first while LiveSync is not configured and separates synchronisation pages from it", () => {
const tab = createSettingsTab();
tab.editingSettings.isConfigured = false;
const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([
@@ -192,14 +225,15 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
]);
});
it("keeps the synchronisation group first and orders General Settings before Quick Setup while synchronisation is active", () => {
it("keeps the synchronisation group first for a configured device with automatic triggers disabled", () => {
const tab = createSettingsTab();
tab.editingSettings.liveSync = true;
tab.editingSettings.isConfigured = true;
const definitions = tab.getSettingDefinitions().filter(isGroup);
expect(definitions.slice(0, 3).map(itemLabel)).toEqual([
expect(definitions.slice(0, 4).map(itemLabel)).toEqual([
"🔄 Synchronisation",
"⚙️ General Settings",
"📲 Set up other devices",
"🧙‍♂️ Quick Setup",
]);
});
@@ -40,7 +40,6 @@ import {
eventHub,
} from "@/common/events.ts";
import {
enableOnly,
// findAttrFromParent,
// getLevelStr,
setLevelClass,
@@ -587,19 +586,8 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
"encrypt",
]);
}
isAnySyncEnabled() {
if (this.isConfiguredAs("isConfigured", false)) return false;
if (this.isConfiguredAs("liveSync", true)) return true;
if (this.isConfiguredAs("periodicReplication", true)) return true;
if (this.isConfiguredAs("syncOnFileOpen", true)) return true;
if (this.isConfiguredAs("syncOnSave", true)) return true;
if (this.isConfiguredAs("syncOnEditorSave", true)) return true;
if (this.isConfiguredAs("syncOnStart", true)) return true;
if (this.isConfiguredAs("syncAfterMerge", true)) return true;
if (this.isConfiguredAs("syncOnFileOpen", true)) return true;
if (this.core?.replicator?.syncStatus == "CONNECTED") return true;
if (this.core?.replicator?.syncStatus == "PAUSED") return true;
return false;
isLiveSyncConfigured() {
return this.isConfiguredAs("isConfigured", true);
}
private supportsDeclarativeSettings(): boolean {
@@ -905,13 +893,20 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
getPage("help"),
getPage("change-log"),
]);
const laterGroups = [setupOtherDevices, maintenance, extraFeatures, advancedSettings, helpAndInformation];
const laterGroups = [maintenance, extraFeatures, advancedSettings, helpAndInformation];
const pendingInitialisation = this.createRebuildRequiredAction();
if (this.isAnySyncEnabled()) {
return [pendingInitialisation, synchronisation, generalSettings, quickSetup, ...laterGroups];
if (this.isLiveSyncConfigured()) {
return [
pendingInitialisation,
synchronisation,
generalSettings,
setupOtherDevices,
quickSetup,
...laterGroups,
];
}
return [pendingInitialisation, quickSetup, synchronisation, generalSettings, ...laterGroups];
return [pendingInitialisation, quickSetup, synchronisation, generalSettings, setupOtherDevices, ...laterGroups];
}
private beginRenderScope(refresh: () => void): Component {
@@ -936,8 +931,6 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
this.controlledElementFunc.length = 0;
}
enableOnlySyncDisabled = enableOnly(() => !this.isAnySyncEnabled());
onlyOnP2POrCouchDB = () =>
({
visibility:
@@ -1184,7 +1177,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
void yieldNextAnimationFrame().then(() => {
if (this.selectedScreen == "") {
if (this.isAnySyncEnabled()) {
if (this.isLiveSyncConfigured()) {
changeDisplay("20");
} else {
changeDisplay("110");