2026-08-01 14:37:08 -04:00
|
|
|
import { React } from "@vendetta/metro/common";
|
2026-08-09 02:52:10 -04:00
|
|
|
import { after } from "@vendetta/patcher";
|
2026-08-01 14:37:08 -04:00
|
|
|
|
|
|
|
|
interface Intercept {
|
|
|
|
|
replacement: React.ComponentType<any>;
|
|
|
|
|
extraProps?: Record<string, any>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 15:28:50 -04:00
|
|
|
interface PropsIntercept {
|
|
|
|
|
predicate: (props: any) => boolean;
|
|
|
|
|
replacement: React.ComponentType<any> | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 02:20:50 -04:00
|
|
|
interface PropsTransform {
|
|
|
|
|
predicate: (props: any) => boolean;
|
|
|
|
|
transform: (props: any) => any;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 21:05:22 -04:00
|
|
|
interface TypeDetector {
|
2026-08-09 02:20:50 -04:00
|
|
|
key: string;
|
2026-08-01 21:05:22 -04:00
|
|
|
predicate: (type: any) => boolean;
|
|
|
|
|
onDetected: (type: any) => void;
|
2026-08-09 02:20:50 -04:00
|
|
|
persistent: boolean;
|
|
|
|
|
justFired?: boolean;
|
2026-08-01 21:05:22 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-01 14:37:08 -04:00
|
|
|
const intercepts = new Map<React.ComponentType<any>, Intercept>();
|
2026-08-01 15:28:50 -04:00
|
|
|
const propsIntercepts: PropsIntercept[] = [];
|
2026-08-09 02:20:50 -04:00
|
|
|
const propsTransforms: PropsTransform[] = [];
|
2026-08-01 21:05:22 -04:00
|
|
|
let typeDetectors: TypeDetector[] = [];
|
2026-08-09 02:20:50 -04:00
|
|
|
const detectorKeys = new Set<string>();
|
2026-08-01 14:37:08 -04:00
|
|
|
let isPatched = false;
|
|
|
|
|
|
|
|
|
|
export function registerIntercept(
|
|
|
|
|
original: React.ComponentType<any>,
|
|
|
|
|
replacement: React.ComponentType<any>,
|
|
|
|
|
extraProps?: Record<string, any>,
|
|
|
|
|
) {
|
|
|
|
|
intercepts.set(original, { replacement, extraProps });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 14:36:37 -04:00
|
|
|
// Same idea as registerIntercept, but matches on a predicate over the element's props instead of
|
|
|
|
|
// an exact type reference - useful when the component has no reliable name to find it by, but
|
|
|
|
|
// always passes some distinguishing prop. Pass replacement: null to render nothing instead.
|
2026-08-01 15:28:50 -04:00
|
|
|
export function registerPropsIntercept(predicate: (props: any) => boolean, replacement: React.ComponentType<any> | null) {
|
|
|
|
|
propsIntercepts.push({ predicate, replacement });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 14:36:37 -04:00
|
|
|
// Rewrites an element's props in place, keeping the same type - unlike registerPropsIntercept,
|
|
|
|
|
// this doesn't swap in a different component, it just lets the real one render with different
|
|
|
|
|
// input. Useful where patching a hook's exported property wouldn't work (same-chunk closures
|
|
|
|
|
// often bypass the export entirely); every element creation goes through this one shared path
|
|
|
|
|
// regardless of which chunk it came from, so there's no equivalent staleness problem here.
|
2026-08-09 02:20:50 -04:00
|
|
|
export function registerPropsTransform(predicate: (props: any) => boolean, transform: (props: any) => any) {
|
|
|
|
|
propsTransforms.push({ predicate, transform });
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 14:36:37 -04:00
|
|
|
// Purely observational: the first time an element is created whose type matches predicate, calls
|
|
|
|
|
// onDetected(type) with the live reference. Doesn't touch rendering itself - pair it with
|
|
|
|
|
// registerIntercept/registerPropsIntercept in the callback for that. This is how you find a
|
|
|
|
|
// component that isn't a top-level module export and can't be searched for after the fact.
|
|
|
|
|
//
|
|
|
|
|
// key dedupes registrations (matters if the call site can run more than once, e.g. a retry loop).
|
|
|
|
|
// persistent keeps the detector alive for every future match instead of firing once and dropping
|
|
|
|
|
// - needed for components whose reference isn't stable for the whole session.
|
2026-08-09 02:20:50 -04:00
|
|
|
export function registerTypeDetector(
|
|
|
|
|
key: string,
|
|
|
|
|
predicate: (type: any) => boolean,
|
|
|
|
|
onDetected: (type: any) => void,
|
|
|
|
|
options?: { persistent?: boolean },
|
|
|
|
|
) {
|
|
|
|
|
if (detectorKeys.has(key)) return;
|
|
|
|
|
detectorKeys.add(key);
|
|
|
|
|
typeDetectors.push({ key, predicate, onDetected, persistent: options?.persistent ?? false });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function hasTypeDetector(key: string): boolean {
|
|
|
|
|
return detectorKeys.has(key);
|
2026-08-01 21:05:22 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runTypeDetectors(type: any) {
|
|
|
|
|
if (typeDetectors.length === 0) return;
|
2026-08-09 02:20:50 -04:00
|
|
|
|
|
|
|
|
let consumed = false;
|
2026-08-01 21:05:22 -04:00
|
|
|
for (const detector of typeDetectors) {
|
|
|
|
|
let matched = false;
|
|
|
|
|
try {
|
|
|
|
|
matched = detector.predicate(type);
|
|
|
|
|
} catch {
|
|
|
|
|
// A bad predicate shouldn't block every other detector or every element from rendering.
|
|
|
|
|
}
|
2026-08-09 02:20:50 -04:00
|
|
|
if (!matched) continue;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
detector.onDetected(type);
|
|
|
|
|
} catch {
|
|
|
|
|
// Same - a detector's own handler throwing shouldn't take anything else down.
|
2026-08-01 21:05:22 -04:00
|
|
|
}
|
2026-08-09 02:20:50 -04:00
|
|
|
if (!detector.persistent) {
|
|
|
|
|
detector.justFired = true;
|
|
|
|
|
consumed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only rebuild the list when a one-shot detector actually fired - rebuilding on every single
|
|
|
|
|
// element creation app-wide would be an allocation per element on the hottest path in the app
|
|
|
|
|
// whenever any persistent detector is registered (the list is then never empty).
|
|
|
|
|
if (consumed) {
|
|
|
|
|
typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired);
|
2026-08-01 21:05:22 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 15:43:09 -04:00
|
|
|
/** Returns a replacement type if this element should be intercepted, `null` to render nothing, or `undefined` to pass through unchanged. */
|
2026-08-09 02:20:50 -04:00
|
|
|
function resolveReplacement(type: any, props: any, rest: any[]): { type: any; props: any } | null | undefined {
|
2026-08-01 21:05:22 -04:00
|
|
|
runTypeDetectors(type);
|
|
|
|
|
|
2026-08-09 02:20:50 -04:00
|
|
|
let effectiveProps = props;
|
|
|
|
|
if (effectiveProps) {
|
|
|
|
|
for (const { predicate, transform } of propsTransforms) {
|
|
|
|
|
try {
|
|
|
|
|
if (predicate(effectiveProps, type, rest)) {
|
|
|
|
|
effectiveProps = transform(effectiveProps);
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// A bad transform shouldn't block every other element from rendering.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (effectiveProps) {
|
2026-08-01 15:43:09 -04:00
|
|
|
for (const { predicate, replacement } of propsIntercepts) {
|
|
|
|
|
try {
|
2026-08-09 02:20:50 -04:00
|
|
|
if (predicate(effectiveProps, type, rest)) {
|
|
|
|
|
return replacement ? { type: replacement, props: effectiveProps } : null;
|
2026-08-01 15:43:09 -04:00
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// A bad predicate shouldn't block every other element from rendering.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const entry = intercepts.get(type);
|
|
|
|
|
if (entry) {
|
2026-08-09 02:20:50 -04:00
|
|
|
return { type: entry.replacement, props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (effectiveProps !== props) {
|
|
|
|
|
return { type, props: effectiveProps };
|
2026-08-01 15:43:09 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 14:36:37 -04:00
|
|
|
// Mutates the already-created element in place rather than substituting args before creation.
|
|
|
|
|
// This has to go through @vendetta/patcher's after() instead of a raw property reassignment on
|
|
|
|
|
// the jsx-runtime object - a plain reassignment (what this file and server-drawer's copy used to
|
|
|
|
|
// do) just doesn't reach real render calls here, after() does. Since after() only sees the return
|
|
|
|
|
// value, not the original call, we mutate res.type/res.props after the fact instead of swapping
|
|
|
|
|
// the type beforehand - same result, React just reads whatever's on the object it gets handed.
|
2026-08-09 02:52:10 -04:00
|
|
|
function applyResolved(res: any, type: any, props: any, rest: any[]) {
|
|
|
|
|
if (!res || typeof res !== "object") return res;
|
|
|
|
|
const resolved = resolveReplacement(type, props, rest);
|
|
|
|
|
if (resolved === null) {
|
|
|
|
|
res.type = () => null;
|
|
|
|
|
return res;
|
|
|
|
|
}
|
|
|
|
|
if (resolved) {
|
|
|
|
|
res.type = resolved.type;
|
|
|
|
|
res.props = resolved.props ?? null;
|
|
|
|
|
}
|
|
|
|
|
return res;
|
2026-08-09 02:20:50 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-01 14:37:08 -04:00
|
|
|
export function patchCreateElement(cleanups: (() => void)[]) {
|
|
|
|
|
if (isPatched) return;
|
2026-08-09 02:52:10 -04:00
|
|
|
isPatched = true;
|
2026-08-01 14:37:08 -04:00
|
|
|
|
2026-08-09 02:52:10 -04:00
|
|
|
if (React.createElement) {
|
|
|
|
|
cleanups.push(
|
|
|
|
|
after("createElement", React, (args: any[], res: any) => applyResolved(res, args[0], args[1], args.slice(2)))
|
|
|
|
|
);
|
|
|
|
|
} else {
|
2026-08-01 21:05:22 -04:00
|
|
|
console.warn("[createElementIntercept] React.createElement is null, skipping patch");
|
2026-08-01 14:37:08 -04:00
|
|
|
}
|
|
|
|
|
|
2026-08-09 14:36:37 -04:00
|
|
|
// Discord's bundle compiles JSX through the automatic runtime (jsx/jsxs/jsxDEV) instead of
|
|
|
|
|
// React.createElement, so patching createElement alone misses nearly everything. We scan for
|
|
|
|
|
// the runtime by shape rather than name since names get mangled, patch every copy we find (a
|
|
|
|
|
// bundle can have more than one), and keep scanning on an interval since Metro registers
|
|
|
|
|
// modules lazily - a copy that hasn't been required yet at boot just isn't there to find.
|
2026-08-09 02:20:50 -04:00
|
|
|
const patchedJsxRuntimes = new WeakSet<any>();
|
|
|
|
|
|
|
|
|
|
function isJsxRuntime(m: any): boolean {
|
|
|
|
|
return typeof m?.jsx === "function" || typeof m?.jsxs === "function" || typeof m?.jsxDEV === "function";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function patchJsxObject(runtime: any) {
|
|
|
|
|
if (patchedJsxRuntimes.has(runtime)) return;
|
|
|
|
|
patchedJsxRuntimes.add(runtime);
|
|
|
|
|
for (const key of ["jsx", "jsxs", "jsxDEV"] as const) {
|
2026-08-09 02:52:10 -04:00
|
|
|
if (typeof runtime[key] !== "function") continue;
|
|
|
|
|
cleanups.push(
|
|
|
|
|
after(key, runtime, (args: any[], res: any) => applyResolved(res, args[0], args[1], args.slice(2)))
|
|
|
|
|
);
|
2026-08-09 02:20:50 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function patchJsxModule(def: any) {
|
|
|
|
|
if (!def?.publicModule?.exports) return;
|
|
|
|
|
const exports = def.publicModule.exports;
|
|
|
|
|
|
|
|
|
|
try {
|
2026-08-09 02:52:10 -04:00
|
|
|
if (isJsxRuntime(exports)) patchJsxObject(exports);
|
2026-08-09 02:20:50 -04:00
|
|
|
|
|
|
|
|
const dflt = exports.default;
|
2026-08-09 02:52:10 -04:00
|
|
|
if (dflt != null && isJsxRuntime(dflt)) patchJsxObject(dflt);
|
2026-08-09 02:20:50 -04:00
|
|
|
} catch {
|
|
|
|
|
// Ignore one module's bad shape and continue scanning.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scanAndPatchJsxRuntimes() {
|
|
|
|
|
const modules = (window as any)?.modules;
|
|
|
|
|
if (!modules) return;
|
|
|
|
|
for (const id in modules) {
|
|
|
|
|
const def = modules[id];
|
|
|
|
|
if (!def?.isInitialized) continue;
|
|
|
|
|
patchJsxModule(def);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Patch any jsx runtimes already loaded before Discord's first render pass starts, synchronously
|
|
|
|
|
// - an async scan would complete too late for the very first render of an early-mounting target.
|
|
|
|
|
scanAndPatchJsxRuntimes();
|
|
|
|
|
|
|
|
|
|
// Fast while the app is still booting and chunks are being pulled in, then slow, then stop - a
|
|
|
|
|
// full window.modules walk is not free and there's nothing left to catch once the UI has settled.
|
|
|
|
|
let ticks = 0;
|
|
|
|
|
let timer: ReturnType<typeof setInterval> | undefined = setInterval(() => {
|
|
|
|
|
scanAndPatchJsxRuntimes();
|
|
|
|
|
if (++ticks === 50 && timer) { // ~5s at 100ms
|
|
|
|
|
clearInterval(timer);
|
|
|
|
|
timer = setInterval(() => {
|
|
|
|
|
scanAndPatchJsxRuntimes();
|
|
|
|
|
if (++ticks >= 75 && timer) { // + ~25s at 1s
|
|
|
|
|
clearInterval(timer);
|
|
|
|
|
timer = undefined;
|
|
|
|
|
}
|
|
|
|
|
}, 1000);
|
|
|
|
|
}
|
|
|
|
|
}, 100);
|
2026-08-01 15:43:09 -04:00
|
|
|
|
2026-08-01 14:37:08 -04:00
|
|
|
cleanups.push(() => {
|
2026-08-09 02:20:50 -04:00
|
|
|
if (timer) clearInterval(timer);
|
|
|
|
|
timer = undefined;
|
2026-08-09 02:52:10 -04:00
|
|
|
isPatched = false;
|
2026-08-01 14:37:08 -04:00
|
|
|
intercepts.clear();
|
2026-08-01 15:28:50 -04:00
|
|
|
propsIntercepts.length = 0;
|
2026-08-09 02:20:50 -04:00
|
|
|
propsTransforms.length = 0;
|
2026-08-01 21:05:22 -04:00
|
|
|
typeDetectors = [];
|
2026-08-09 02:20:50 -04:00
|
|
|
detectorKeys.clear();
|
2026-08-01 14:37:08 -04:00
|
|
|
});
|
|
|
|
|
}
|