Bug - onExternalSettingsChange() not firing

:wave: I’ve got an issue with the onExternalSettingsChange() callback. I can’t get it to fire. I’m wondering if this is a skill issue on my part, or if there is a bug in Obsidian.

I was expecting this callback to fire when I modify a plugin’s data.json file externally. For instance, modifying a JSON value from my code editor and saving the file. However, this doesn’t appear to be the case. When I modify and save the file, the callback does not execute. The docs seem to suggest my expectation is how the callback is meant to work.

I have been able to reproduce this on Windows 11 and MacOS. Debug info from my mac:

SYSTEM INFO:
	Obsidian version: v1.8.9
	Installer version: v1.8.4
	Operating system: Darwin Kernel Version 24.4.0: Wed Mar 19 21:16:34 PDT 2025; root:xnu-11417.101.15~1/RELEASE_ARM64_T6000 24.4.0
	Login status: logged in
	Language: en
	Catalyst license: none
	Insider build toggle: off
	Live preview: on
	Base theme: adapt to system
	Community theme: none
	Snippets enabled: 0
	Restricted mode: off
	Plugins installed: 5
	Plugins enabled: 1
		1: Sample Plugin v1.0.0

RECOMMENDATIONS:
	Community plugins: for bugs, please first try updating all your plugins to latest. If still not fixed, please try to make the issue happen in the Sandbox Vault or disable community plugins.

To reproduce,

  1. create a new vault, enable community plugins
  2. clone the plugin template to .obsidian/plugins: git clone https://github.com/obsidianmd/obsidian-sample-plugin.git
  3. add the following method to MyPlugin in main.ts
onExternalSettingsChange() {
	console.log("External settings changed!");
}
  1. build the plugin pnpm build
  2. reload obsidian, enable the plugin
  3. change the plugin’s settings from the settings page in Obsidian to get data.json created
  4. modify data.json outside of Obsidian (ie. from your editor)
  5. observe in the debug console that External settings changed! was never printed

Am I missing something? Or is this a bug in Obsidian. Thanks.

Can you paste your full code?
Just to make sure you pasted that code in the correct place.

Sure, here is the code in main.js. This is the standard plugin template, with two additions

  1. Added the onExternalSettingsChange callback
  2. Added a print statement to the top of the onload callback

I build the plugin with pnpm build.

I can confirm I am using the modified version of the plugin since I see the Plugin loaded! message in the console on load.

import {
	App,
	Editor,
	MarkdownView,
	Modal,
	Notice,
	Plugin,
	PluginSettingTab,
	Setting,
} from "obsidian";

// Remember to rename these classes and interfaces!

interface MyPluginSettings {
	mySetting: string;
}

const DEFAULT_SETTINGS: MyPluginSettings = {
	mySetting: "default",
};

export default class MyPlugin extends Plugin {
	settings: MyPluginSettings;

	onExternalSettingsChange() {
		console.log("External settings changed!");
	}

	async onload() {
		console.log("Plugin loaded!");
		await this.loadSettings();

		// This creates an icon in the left ribbon.
		const ribbonIconEl = this.addRibbonIcon(
			"dice",
			"Sample Plugin",
			(evt: MouseEvent) => {
				// Called when the user clicks the icon.
				new Notice("This is a notice!");
			},
		);
		// Perform additional things with the ribbon
		ribbonIconEl.addClass("my-plugin-ribbon-class");

		// This adds a status bar item to the bottom of the app. Does not work on mobile apps.
		const statusBarItemEl = this.addStatusBarItem();
		statusBarItemEl.setText("Status Bar Text");

		// This adds a simple command that can be triggered anywhere
		this.addCommand({
			id: "open-sample-modal-simple",
			name: "Open sample modal (simple)",
			callback: () => {
				new SampleModal(this.app).open();
			},
		});
		// This adds an editor command that can perform some operation on the current editor instance
		this.addCommand({
			id: "sample-editor-command",
			name: "Sample editor command",
			editorCallback: (editor: Editor, view: MarkdownView) => {
				console.log(editor.getSelection());
				editor.replaceSelection("Sample Editor Command");
			},
		});
		// This adds a complex command that can check whether the current state of the app allows execution of the command
		this.addCommand({
			id: "open-sample-modal-complex",
			name: "Open sample modal (complex)",
			checkCallback: (checking: boolean) => {
				// Conditions to check
				const markdownView =
					this.app.workspace.getActiveViewOfType(MarkdownView);
				if (markdownView) {
					// If checking is true, we're simply "checking" if the command can be run.
					// If checking is false, then we want to actually perform the operation.
					if (!checking) {
						new SampleModal(this.app).open();
					}

					// This command will only show up in Command Palette when the check function returns true
					return true;
				}
			},
		});

		// This adds a settings tab so the user can configure various aspects of the plugin
		this.addSettingTab(new SampleSettingTab(this.app, this));

		// If the plugin hooks up any global DOM events (on parts of the app that doesn't belong to this plugin)
		// Using this function will automatically remove the event listener when this plugin is disabled.
		this.registerDomEvent(document, "click", (evt: MouseEvent) => {
			console.log("click", evt);
		});

		// When registering intervals, this function will automatically clear the interval when the plugin is disabled.
		this.registerInterval(
			window.setInterval(() => console.log("setInterval"), 5 * 60 * 1000),
		);
	}

	onunload() {}

	async loadSettings() {
		this.settings = Object.assign(
			{},
			DEFAULT_SETTINGS,
			await this.loadData(),
		);
	}

	async saveSettings() {
		await this.saveData(this.settings);
	}
}

class SampleModal extends Modal {
	constructor(app: App) {
		super(app);
	}

	onOpen() {
		const { contentEl } = this;
		contentEl.setText("Woah!");
	}

	onClose() {
		const { contentEl } = this;
		contentEl.empty();
	}
}

class SampleSettingTab extends PluginSettingTab {
	plugin: MyPlugin;

	constructor(app: App, plugin: MyPlugin) {
		super(app, plugin);
		this.plugin = plugin;
	}

	display(): void {
		const { containerEl } = this;

		containerEl.empty();

		new Setting(containerEl)
			.setName("Setting #1")
			.setDesc("It's a secret")
			.addText((text) =>
				text
					.setPlaceholder("Enter your secret")
					.setValue(this.plugin.settings.mySetting)
					.onChange(async (value) => {
						this.plugin.settings.mySetting = value;
						await this.plugin.saveSettings();
					}),
			);
	}
}

main.js

Sorry, meant main.ts.

bumping this. Should I move it to another topic?

hm, that should work