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.
- EvaluateTop-level code runs
- start()Set everything up
- Running
- stop()Undo the rest
- 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.
| Export | Type | Called |
|---|---|---|
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 |
SettingsPanel | React component | Rendered 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
startorstopruns, and after a failed start. - Closes an open panel into a notice if the plugin stops.
Globals
| Global | What it is |
|---|---|
unbound | The API. patcher, commands, metro, storage, flux, settings and assets are the plugin's own |
addon | The plugin: id, name, version, manifest and its state |
logger | Prefixes every line with the plugin's name |
setTimeout, setInterval | Cleared when the plugin stops |
React | React |
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
setTimeoutandsetInterval. metro.onModuleLoadlisteners, and unsettledmetro.onModuleLoadedandmetro.findLazywaits.storage.onChangelisteners.flux.onStoreChange,flux.onActionandflux.interceptActionlisteners.settings.registerSectionsections.assets.onAssetRegisteredlisteners.
This feature needs no stop; the scope removes its Flux listener.
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:
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.
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.
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
Keep lookups lazy
Use the lazy
findBy*helpers. The search runs the first time you read the result. See Finding Modules.Load heavy code on demand
Move code that pulls in many modules into its own file and
import()it insidestart.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
| Entry | Result |
|---|---|
| A default export | Fails. Use named exports, or run ubd migrate entry to rewrite export default { start, stop, getSettingsPanel } |
A misspelt name, such as Start, onStart or getSettingsPanel | Fails, naming the right export |
A stop with parameters, or an async SettingsPanel | Fails |
| Any other export | Warns. 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.