YouBar+: catch the component via element-creation interception, not metro search

This commit is contained in:
everestmcarthur 2026-08-01 21:05:22 -04:00
parent abb019ae6f
commit 3c42faa98c
5 changed files with 137 additions and 65 deletions

View file

@ -7,7 +7,7 @@ import { patchMobileQuestDock } from "./patches/mobileQuestDock";
import { patchGetQuestAsset } from "./patches/getQuestAsset";
import { patchExpanded, patchEmpty } from "./patches/contentPatch";
import { patchHideGuildsBar } from "./patches/hideGuildsBar";
import { patchCreateElement } from "./patches/createElementIntercept";
import { patchCreateElement } from "@shared/lib/createElementIntercept";
import { patchAutoCollapseFolders } from "./patches/autoCollapseFolders";
import Settings from "./ui/Settings";

View file

@ -1,7 +1,7 @@
import { React } from "@vendetta/metro/common";
import { rawFind } from "@shared/lib/rawFind";
import ServerDrawerSheet from "../components/ServerDrawerSheet";
import { registerIntercept } from "./createElementIntercept";
import { registerIntercept } from "@shared/lib/createElementIntercept";
const TAG = "[ServerDrawer]";

View file

@ -1,4 +1,4 @@
import { registerPropsIntercept } from "./createElementIntercept";
import { registerPropsIntercept } from "@shared/lib/createElementIntercept";
const TAG = "[ServerDrawer]";

View file

@ -5,38 +5,63 @@ import { storage } from "@vendetta/plugin";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { waitFor } from "@shared/lib/waitFor";
import { rawFindByTypeName } from "@shared/lib/rawFind";
import { patchCreateElement, registerTypeDetector } from "@shared/lib/createElementIntercept";
export let updateYouBar = () => {};
function isYouBarNotificationsButton(type: any): boolean {
return (
type?.name === "YouBarNotificationsButton" ||
type?.displayName === "YouBarNotificationsButton" ||
type?.type?.name === "YouBarNotificationsButton" ||
type?.type?.displayName === "YouBarNotificationsButton"
);
}
/**
* Confirmed on-device, not just theorized: the exact upstream one-shot `findByTypeName` lookup at
* onLoad is genuinely non-deterministic across app boots - it found the component on one reload and
* came up empty on the very next one, same code, same device, nothing else changed. Metro doesn't
* guarantee YouBarNotificationsButton's module has been required by the time a plugin's onLoad
* runs; which reload wins that race varies. waitFor keeps checking after a first miss instead of
* giving up for the whole session - and it has to use the passive rawFindByTypeName specifically,
* because Revenge's own findByTypeName permanently caches a negative result and never rescans,
* which would make retrying it pointless.
* Confirmed on-device that a metro-search-based lookup (whether the original one-shot
* findByTypeName, or this repo's own waitFor + passive rawFindByTypeName retry) is fundamentally
* racing against something it can't always win: YouBarNotificationsButton is a React.memo'd leaf
* that, once mounted, appears to never render again for the rest of the session (also confirmed
* on-device - patching it and watching for 20+ real seconds on the main screen never saw another
* render). If that first, only mount happens before our search finds and patches the component,
* the patch is permanently too late no matter how many times the search retries afterward.
*
* Deliberately NOT reintroducing anything beyond this. The force-render attempts tried earlier this
* session (walking React's DevTools fiber registry, nudging live navigation state) were aimed at a
* different problem - buttons not appearing on a hot toggle without ever restarting - and one of
* them caused a real regression. This only fixes the reload-timing race; "no restart ever needed"
* is a separate, deliberately deferred piece of work.
* registerTypeDetector sidesteps the race by watching element *creation* instead of searching the
* module registry after the fact: the moment ANY code calls createElement/jsx with this component
* as `type` - which is necessarily the actual live reference already, there's no way to call it any
* earlier - we grab it and patch immediately, before that render even happens. This still needs
* patchCreateElement to have installed its React.createElement/jsx-runtime hooks before that first
* call, but jsx-runtime is about as foundational and early-loaded a module as React itself, since
* literally nothing can render via JSX without it - a much safer bet than waiting on a specific leaf
* UI component to register.
*
* The waitFor + rawFindByTypeName search stays too, purely as a fallback for the case where the
* component was already mounted (and thus already searchable) *before* this plugin's onLoad even
* ran - e.g. toggled on well after a cold boot completed - since in that case there's no future
* creation call left to intercept. Both paths funnel through the same `patchOnce`, deduped by
* reference, so if both somehow fire for the same live component it only gets patched once.
*/
export default function patchYouBarButtons(): () => void {
let unpatch: () => void = () => {};
const cleanups: (() => void)[] = [];
let buttonUnpatch: () => void = () => {};
let patchedTarget: any = null;
const handle = waitFor(
() => rawFindByTypeName("YouBarNotificationsButton"),
(YouBarNotificationsButton) => {
unpatch = applyButtonPatch(YouBarNotificationsButton);
function patchOnce(target: any) {
if (!target || target === patchedTarget) return;
patchedTarget = target;
buttonUnpatch = applyButtonPatch(target);
}
);
patchCreateElement(cleanups);
registerTypeDetector(isYouBarNotificationsButton, patchOnce);
const handle = waitFor(() => rawFindByTypeName("YouBarNotificationsButton"), patchOnce);
cleanups.push(() => handle.cancel());
return () => {
handle.cancel();
unpatch();
buttonUnpatch();
cleanups.forEach((fn) => fn());
};
}
@ -47,12 +72,11 @@ function applyButtonPatch(YouBarNotificationsButton: any): () => void {
const SettingsIcon = getAssetIDByName("SettingsIcon");
return instead("type", YouBarNotificationsButton, (args: any[], OriginalRender: (...a: any[]) => any) => {
try {
const [, forceUpdate] = React.useReducer((x: number) => ~x, 0);
updateYouBar = () => forceUpdate();
const res = OriginalRender(...args);
if (!res?.props?.children) return res;
const IconButton = res.props.children.type;
@ -65,24 +89,23 @@ function applyButtonPatch(YouBarNotificationsButton: any): () => void {
variant={originalProps?.variant || "tertiary"}
size={originalProps?.size || "sm"}
icon={ChatIcon}
onPress={() => {
transitionModule?.transitionToGuild?.("@me");
}}
onPress={() => transitionModule?.transitionToGuild?.("@me")}
/>
)}
{storage.showSettingsButton && (
<IconButton
variant={originalProps?.variant || "tertiary"}
size={originalProps?.size || "sm"}
icon={SettingsIcon}
onPress={() => {
userSettingsAction?.openUserSettings?.();
}}
onPress={() => userSettingsAction?.openUserSettings?.()}
/>
)}
{res}
</React.Fragment>
);
} catch {
// A shape mismatch here should mean "stock YouBar, no extra buttons" - not a crash.
return OriginalRender(...args);
}
});
}

View file

@ -11,8 +11,14 @@ interface PropsIntercept {
replacement: React.ComponentType<any> | null;
}
interface TypeDetector {
predicate: (type: any) => boolean;
onDetected: (type: any) => void;
}
const intercepts = new Map<React.ComponentType<any>, Intercept>();
const propsIntercepts: PropsIntercept[] = [];
let typeDetectors: TypeDetector[] = [];
let isPatched = false;
export function registerIntercept(
@ -34,8 +40,51 @@ export function registerPropsIntercept(predicate: (props: any) => boolean, repla
propsIntercepts.push({ predicate, replacement });
}
/**
* Purely observational, one-shot: the first time an element is created whose `type` matches
* `predicate`, calls `onDetected(type)` with the real, live type reference and then stops
* watching - it never alters what actually renders.
*
* This exists for components that can't be found reliably by searching Metro's module registry
* after the fact (findByTypeName/rawFindByTypeName), because that races against whenever the
* component first mounts - if it mounts before the search finds it, and it never mounts again for
* the rest of the session (true for some React.memo'd chrome components), the search losing that
* race means the patch never gets a chance to apply at all, permanently. Intercepting element
* creation instead sidesteps the race entirely: by the time ANYTHING calls createElement/jsx with
* this component as `type`, that reference already exists and is already the real one about to be
* used - there's no way to observe the call any earlier than this.
*/
export function registerTypeDetector(predicate: (type: any) => boolean, onDetected: (type: any) => void) {
typeDetectors.push({ predicate, onDetected });
}
function runTypeDetectors(type: any) {
if (typeDetectors.length === 0) return;
const remaining: TypeDetector[] = [];
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.
}
if (matched) {
try {
detector.onDetected(type);
} catch {
// Same - a detector's own handler throwing shouldn't take anything else down.
}
} else {
remaining.push(detector);
}
}
typeDetectors = remaining;
}
/** Returns a replacement type if this element should be intercepted, `null` to render nothing, or `undefined` to pass through unchanged. */
function resolveReplacement(type: any, props: any): { type: any; props: any } | null | undefined {
runTypeDetectors(type);
if (props) {
for (const { predicate, replacement } of propsIntercepts) {
try {
@ -61,7 +110,7 @@ export function patchCreateElement(cleanups: (() => void)[]) {
const origCreateElement = React.createElement;
if (!origCreateElement) {
console.warn("[ServerDrawer] React.createElement is null, skipping patch");
console.warn("[createElementIntercept] React.createElement is null, skipping patch");
return;
}
@ -78,10 +127,9 @@ export function patchCreateElement(cleanups: (() => void)[]) {
// Modern React/RN builds (Discord's included) compile JSX through the automatic runtime
// (jsx/jsxs from react/jsx-runtime) instead of React.createElement - patching only
// createElement misses every one of Discord's own render calls entirely, which is why
// hideGuildsBar's intercept never actually did anything even though it registered correctly.
// Found by shape (jsx/jsxs/Fragment together), not by name, for the same reason everything
// else in this file avoids name-based lookups.
// createElement misses every one of Discord's own render calls entirely. Found by shape
// (jsx/jsxs/Fragment together), not by name, for the same reason everything else in this file
// avoids name-based lookups.
const jsxRuntime = rawFind((m: any) => typeof m?.jsx === "function" && typeof m?.jsxs === "function" && "Fragment" in m);
const restoreJsx: (() => void)[] = [];
@ -99,7 +147,7 @@ export function patchCreateElement(cleanups: (() => void)[]) {
restoreJsx.push(() => { jsxRuntime[key] = orig; });
}
} else {
console.warn("[ServerDrawer] jsx-runtime module not found, only classic createElement calls will be intercepted");
console.warn("[createElementIntercept] jsx-runtime module not found, only classic createElement calls will be intercepted");
}
cleanups.push(() => {
@ -110,5 +158,6 @@ export function patchCreateElement(cleanups: (() => void)[]) {
restoreJsx.forEach((fn) => fn());
intercepts.clear();
propsIntercepts.length = 0;
typeDetectors = [];
});
}