Patching Components

Change what Discord's components render.

Patch a component's render, usually with after, and edit the tree it returns. Your changes join Discord's real tree, so they keep its context and survive re-renders. Builds on Patching Functions.

Quick Start

Pass the module holding the component and its key, then return the tree you want.

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

export function startToastPatch() {
	const Toasts = metro.findByProps('ToastContainer');

	if (!Toasts) {
		logger.warn('Toast patch skipped: Discord moved ToastContainer.');
		return;
	}

	patcher.react.after(Toasts, 'ToastContainer', ({ props, result }) => {
		// wrap, replace, or return the original tree
		return result;
	});
}

The patch waits for a lazy lookup to load, never loading it early.

Reference

Each call returns a function that removes the patch, and takes { once: true } as its last argument.

CallRunsCallback returns
patcher.react.before(target, callback, options?)
patcher.react.before(parent, key, callback, options?)
Before the renderNew props, or nothing
patcher.react.after(target, callback, options?)
patcher.react.after(parent, key, callback, options?)
After the renderA tree to replace result, or nothing
patcher.react.instead(target, callback, options?)
patcher.react.instead(parent, key, callback, options?)
In place of itThe tree to render
FieldHolds
propsThis render's props. For a class, this.props
resultWhat rendered, after earlier patches. after only
originalRenders the unpatched component. New props apply to function components only
thisThe class instance, or undefined for a function component

Targets

ComponentPasspatcher.react patches
React.memoItself, or parent and key.type, unwrapping a forwardRef or class inside
React.forwardRefItself, or parent and key.render
ClassItself, or parent and keyprototype.render
Plain functionParent and key only. Alone, it throwsThe key that holds it

Tree Helpers

FunctionReturns
utils.react.findComponent(tree, match)First element match picks, or undefined
utils.react.filterComponents(tree, match)Every element match picks
utils.findInTree(root, predicate, options?)
utils.filterInTree(root, predicate, options?)
First match, or every match, in any object
utils.react.findInTree(root, predicate, options?)
utils.react.filterInTree(root, predicate, options?)
The same, through React element and fiber keys
utils.react.useShallow(selector), utils.react.shallow(a, b)A selector that skips shallow-equal re-renders, and its comparison

match is a component name, a host type such as 'RCTView', or a function returning true for an element.

Full signatures: patcher and utils.react in the SDK reference.

Patch Kinds

Return a tree to replace result, or nothing to keep it.

import { metro, patcher } from '@unbound-app/api';

export function patchUserProfile() {
	const Profile = metro.findByProps('UserProfile');

	if (!Profile) {
		logger.warn('Profile patch skipped: Discord moved UserProfile.');
		return;
	}

	patcher.react.after(Profile, 'UserProfile', ({ props, result }) => {
		logger.log('Rendering profile of', props.userId);

		return result;
	});
}

Editing the Tree

Each element has type, props and props.children. findComponent returns the first match.

import { metro, patcher, utils } from '@unbound-app/api';

export function patchUserProfile() {
	const Profile = metro.findByProps('UserProfile');

	if (!Profile) {
		logger.warn('Faded rows skipped: Discord moved UserProfile.');
		return;
	}

	patcher.react.after(Profile, 'UserProfile', ({ result }) => {
		const row = utils.react.findComponent(result, 'Row');

		if (row) {
			row.props.style = [row.props.style, { opacity: 0.5 }];
		}

		return result;
	});
}
  • Guard every lookup. It returns undefined when Discord or another plugin changed the tree.
  • Add to children; replacing them removes what other plugins added.
  • Patch the smallest component that works. A large, often-rendered one runs your code every render.

Child Components

A child created fresh on each parent render can't hold a lasting patch. Patch it inside the parent's patch with { once: true }, so it lasts one render.

import { metro, patcher, utils } from '@unbound-app/api';

export function patchUserProfile() {
	const Profile = metro.findByProps('UserProfile');

	if (!Profile) {
		logger.warn('Badge patch skipped: Discord moved UserProfile.');
		return;
	}

	patcher.react.after(Profile, 'UserProfile', ({ result }) => {
		const child = utils.react.findComponent(result, 'ProfileBadges');

		if (child && typeof child.type !== 'string') {
			patcher.react.after(child.type, ({ result: badges }) => badges, { once: true });
		}

		return result;
	});
}

This needs child.type to be a memo, forwardRef or class. Patch a plain function where its module exports it.

Class Components

patcher.react patches only render. Check where another member lives first:

import { metro } from '@unbound-app/api';

const SomeClass = metro.findByName('SomeClass', { lazy: false });

if (SomeClass) {
	Object.hasOwn(SomeClass.prototype, 'componentDidMount'); // true: a prototype method
	Object.hasOwn(SomeClass, 'getDerivedStateFromProps'); // true: a static method
	Object.hasOwn(SomeClass.prototype, 'onPress'); // false: a class field, on each instance
}

Lifecycle methods and name() {} methods. Patch the prototype; mounted instances pick it up on their next call. componentDidMount patches only reach instances mounted later.

import { metro, patcher } from '@unbound-app/api';

export function patchSomeClass() {
	const SomeClass = metro.findByName('SomeClass');

	if (!SomeClass) {
		logger.warn('Update log skipped: Discord moved SomeClass.');
		return;
	}

	patcher.after(SomeClass.prototype, 'componentDidUpdate', ({ this: instance }) => {
		logger.log('Updated with', instance.props);
	});
}

Changing Class Props

A class patch can't change props: before can't replace this.props, and original ignores new props. Patch the parent and swap the element's type for a function that renders the class with your props.

import { metro, patcher, utils } from '@unbound-app/api';

const ProfileRow = metro.findByName('ProfileRow');

// Declared once at the top level: a new function on each render would remount the row every time.
function CompactProfileRow(props: Record<string, unknown>) {
	return ProfileRow ? <ProfileRow {...props} compact /> : null;
}

export function patchUserProfile() {
	const Profile = metro.findByProps('UserProfile');

	if (!Profile || !ProfileRow) {
		logger.warn('Compact rows skipped: Discord moved UserProfile or ProfileRow.');
		return;
	}

	patcher.react.after(Profile, 'UserProfile', ({ result }) => {
		const row = utils.react.findComponent(result, 'ProfileRow');

		if (row) row.type = CompactProfileRow;

		return result;
	});
}

The same function each render keeps the row's state. The swap goes away with the patch.

Undoing a Component Patch

The scope removes your patches when the plugin stops, as does each patch's returned function. Mounted components re-render with Discord's render and keep their state, even if your patch called hooks.

stop still undoes changes made outside the render, such as a value written onto a Discord object. If stop also redraws, remove your patches first; see Lifecycle.

When a Patch Crashes

A throwing component patch can't crash Discord. What renders depends on what threw:

What threwWhat rendersDiscord's UI remounts?Counts toward removal?
An element you added beside Discord'sNothing in its place, until it unmountsNoNo, only logged
Your callbackDiscord's tree before your patchYes, if you changed its shapeYes
Anything else in your treeDiscord's tree from that render, until unmountYes, below the componentYes

Aim for the first row: a focused chat input keeps its text and the keyboard stays open.

Added Elements

An element counts as added when it contains none of Discord's elements: beside Discord's children, pushed into them, or in a Fragment with them.

  • Each is wrapped in a small error boundary keyed like the element. A parent reading children's props sees the boundary.
  • An element with the same type as a Discord sibling stays unwrapped, since the parent may treat it as a list item.
  • A tree with none of Discord's elements replaced Discord's; the other rows apply.
  • A wrapper around Discord's elements, such as a provider around result, isn't isolated; the last row applies.

Discord's elements are tracked in a WeakSet. Each patched render costs about 3 µs more.

Removal

  • A single crash only logs the error, naming the component and the added element if one crashed.
  • On a patch's first failure, the component renders nothing for one frame, then re-renders with a saved copy of Discord's tree. Patches that never failed don't keep a copy.
  • After 3 crashes in a row, Unbound removes the patch and shows a toast. A clean commit resets the count. Every patch that built the crashing tree counts it.
  • A removed patch stays off until the plugin starts again.

Later after patches get your tree as result, not the boundary. Text, null and trees that can't throw aren't wrapped.

Searching Any Tree

utils.findInTree walks any object depth-first and returns the first match. utils.filterInTree returns every match.

import { utils } from '@unbound-app/api';

declare const state: unknown;

const channel = utils.findInTree(state, (node) => node?.type === 'GUILD_TEXT');

utils.react.findInTree and filterInTree walk only downward through props, children, type, child, sibling, memoizedProps, memoizedState and stateNode. Start from an element, a fiber, or a class instance's _reactInternals.

import { utils } from '@unbound-app/api';

declare const instance: { _reactInternals: unknown };

const fiber = utils.react.findInTree(
	instance._reactInternals,
	(node) => node?.memoizedProps?.channelId,
);
OptionDoes
walkableutils.findInTree: the only keys to walk. utils.react: extra keys, such as 'return' to go up a fiber tree
ignoreKeys never to walk
maxDepthLevels to walk below the start. Default 100

Searches skip values already seen, so cycles can't loop. See utils in the SDK reference.

Next