Lifecycle

Start, stop, and what the Addon scope cleans up.

What happens to a plugin from the moment the user turns it on. Each start evaluates the bundle again with a fresh Addon scope, then calls start.

  1. EvaluateTop-level code runs
  2. start()Set everything up
  3. Running
  4. stop()Undo the rest
  5. Scope disposedRemoves what it recorded

Top-level code runs before start and, at launch, delays Discord. Keep it cheap.

Exports

The entry exports its lifecycle as named functions. All are optional.

ExportTypeCalled
start(context: PluginContext) => void | Promise<void>When turned on, and at launch if on. A rejected promise is a failed start
stop() => void | Promise<void>When turned off. Awaited. The scope is disposed after it, even if it throws
SettingsPanelReact componentRendered as <SettingsPanel />, with no props, when the user opens the plugin's settings. See Settings and Storage
selfTest() => { ok: boolean; detail: string } | Promise<…>After a restart, by the device matrix, unless the plugin warned while starting. Unbound builds one from those warnings when you export none. See Plugin Anatomy

The settings button on the plugin's card:

  • Shows while the plugin has a panel, and opens it only while the plugin runs.
  • Is disabled while an async start or stop runs, and after a failed start.
  • Closes an open panel into a notice if the plugin stops.

Globals

GlobalWhat it is
unboundThe API. patcher, commands, metro, storage, flux, settings and assets are the plugin's own
addonThe plugin: id, name, version, manifest and its state
loggerPrefixes every line with the plugin's name
setTimeout, setIntervalCleared when the plugin stops
ReactReact

The Addon Scope

Imports from @unbound-app/api read the scoped unbound, so patcher is the plugin's own. After stop returns, the scope removes, newest first:

  • Patches and slash commands.
  • Timers from setTimeout and setInterval.
  • metro.onModuleLoad listeners, and unsettled metro.onModuleLoaded and metro.findLazy waits.
  • storage.onChange listeners.
  • flux.onStoreChange, flux.onAction and flux.interceptAction listeners.
  • settings.registerSection sections.
  • assets.onAssetRegistered listeners.

This feature needs no stop; the scope removes its Flux listener.

src/channel-log/index.ts
import { flux, storage } from '@unbound-app/api';

const settings = storage.getBucket();

export function startChannelLog() {
	flux.onAction('CHANNEL_SELECT', ({ channelId }) => {
		if (!channelId) return;

		settings.set('lastChannel', channelId);
		logger.log('Selected', channelId);
	});
}

Themes, icon packs and fonts ship no code, so they have no scope.

What Stop Must Undo

The scope sees only what went through it. Undo these in stop:

  • A listener added straight to a Discord module.
  • A built-in from builtins.register.
  • A Discord or Unbound settings section you removed.
  • A change a patch callback made outside the render, such as a value written onto a Discord object.

Unpatch Before Redrawing

The scope removes patches after stop returns. A stop that makes Discord redraw, such as with emitChange, would still draw through your patch. Call patcher.unpatchAll() first; it removes only this plugin's patches:

src/index.tsx
import { flux, patcher } from '@unbound-app/api';

const UserStore = flux.getStore('User');

export function start() {
	patcher.after(UserStore, 'getCurrentUser', ({ result }) => {
		return result && { ...result, premiumType: 2 };
	});
	UserStore.emitChange();
}

export function stop() {
	patcher.unpatchAll();
	// The patch is gone, so Discord redraws with the real user.
	UserStore.emitChange();
}

Component patches need no such care. Mounted components redraw with Discord's render when the patch is removed. See Patching Components.

Starting Features

start and stop only list features. stop calls patcher.unpatchAll(), then any cleanup the patcher does not own, in the order it matters.

src/index.tsx
import { patcher } from '@unbound-app/api';

import { patchMemberList } from '~/patches/member-list';
import { startChannelLog } from '~/channel-log';

export function start() {
	patchMemberList();
	startChannelLog();
}

export function stop() {
	patcher.unpatchAll();
}

Each feature checks its own needs first, and a failed check returns early. A setting that is off is silent; a missing module is one logger.warn naming the feature and what moved. Checks never throw: a feature that throws is a bug, and Unbound logs the failed start and disposes the scope.

src/patches/member-list.ts
import { metro, patcher } from '@unbound-app/api';
import { settings } from '@unbound-app/settings';

declare function drawIndicators(): void;

export function patchMemberList() {
	if (!settings.memberList) {
		return;
	}

	const UserRow = metro.findByFilePath('modules/members/native/UserRow.tsx');

	if (!UserRow) {
		logger.warn('Member list skipped: Discord moved UserRow.');
		return;
	}

	patcher.after(UserRow, 'default', drawIndicators);
}

Name a feature after what it does: patchX patches Discord's X, interceptX intercepts a Flux action, and anything else is a plain verb such as startSaving or clearLog. State several features share, such as a log, is its own module they import.

Staying Fast

  1. Keep lookups lazy

    Use the lazy findBy* helpers. The search runs the first time you read the result. See Finding Modules.

  2. Load heavy code on demand

    Move code that pulls in many modules into its own file and import() it inside start.

    src/editor/index.ts
    export function startEditor() {
    	logger.log('Editor ready');
    }
    src/index.tsx
    export async function start() {
    	const { startEditor } = await import('~/editor');
    
    	startEditor();
    }

Build Checks

EntryResult
A default exportFails. Use named exports, or run ubd migrate entry to rewrite export default { start, stop, getSettingsPanel }
A misspelt name, such as Start, onStart or getSettingsPanelFails, naming the right export
A stop with parameters, or an async SettingsPanelFails
Any other exportWarns. Unbound ignores it. Put settings button handlers in src/actions.ts

A plugin built before named exports still runs. Unbound reads its default object's start, stop and getSettingsPanel, and a developer build logs a note asking you to rebuild.

Next