From 3c42faa98c77e9d8ba7e1054892f257d1383da4e Mon Sep 17 00:00:00 2001
From: everestmcarthur <280397636+everestmcarthur@users.noreply.github.com>
Date: Sat, 1 Aug 2026 21:05:22 -0400
Subject: [PATCH] YouBar+: catch the component via element-creation
interception, not metro search
---
plugins/server-drawer/src/index.ts | 2 +-
.../src/patches/contentPatch.tsx | 2 +-
.../src/patches/hideGuildsBar.tsx | 2 +-
.../src/patches/youBarButtons.tsx | 135 ++++++++++--------
.../lib}/createElementIntercept.ts | 61 +++++++-
5 files changed, 137 insertions(+), 65 deletions(-)
rename {plugins/server-drawer/src/patches => shared/lib}/createElementIntercept.ts (63%)
diff --git a/plugins/server-drawer/src/index.ts b/plugins/server-drawer/src/index.ts
index 35208a6..c78f5b6 100644
--- a/plugins/server-drawer/src/index.ts
+++ b/plugins/server-drawer/src/index.ts
@@ -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";
diff --git a/plugins/server-drawer/src/patches/contentPatch.tsx b/plugins/server-drawer/src/patches/contentPatch.tsx
index 37ebf1d..a30e4e5 100644
--- a/plugins/server-drawer/src/patches/contentPatch.tsx
+++ b/plugins/server-drawer/src/patches/contentPatch.tsx
@@ -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]";
diff --git a/plugins/server-drawer/src/patches/hideGuildsBar.tsx b/plugins/server-drawer/src/patches/hideGuildsBar.tsx
index 3cc2fd0..b288694 100644
--- a/plugins/server-drawer/src/patches/hideGuildsBar.tsx
+++ b/plugins/server-drawer/src/patches/hideGuildsBar.tsx
@@ -1,4 +1,4 @@
-import { registerPropsIntercept } from "./createElementIntercept";
+import { registerPropsIntercept } from "@shared/lib/createElementIntercept";
const TAG = "[ServerDrawer]";
diff --git a/plugins/you-bar-plus/src/patches/youBarButtons.tsx b/plugins/you-bar-plus/src/patches/youBarButtons.tsx
index 3a3668e..24f69f0 100644
--- a/plugins/you-bar-plus/src/patches/youBarButtons.tsx
+++ b/plugins/you-bar-plus/src/patches/youBarButtons.tsx
@@ -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,42 +72,40 @@ function applyButtonPatch(YouBarNotificationsButton: any): () => void {
const SettingsIcon = getAssetIDByName("SettingsIcon");
return instead("type", YouBarNotificationsButton, (args: any[], OriginalRender: (...a: any[]) => any) => {
- const [, forceUpdate] = React.useReducer((x: number) => ~x, 0);
+ try {
+ const [, forceUpdate] = React.useReducer((x: number) => ~x, 0);
+ updateYouBar = () => forceUpdate();
- updateYouBar = () => forceUpdate();
+ const res = OriginalRender(...args);
+ if (!res?.props?.children) return res;
- const res = OriginalRender(...args);
+ const IconButton = res.props.children.type;
+ const originalProps = res.props.children.props;
- if (!res?.props?.children) return res;
-
- const IconButton = res.props.children.type;
- const originalProps = res.props.children.props;
-
- return (
-
- {storage.showDMButton && (
- {
- transitionModule?.transitionToGuild?.("@me");
- }}
- />
- )}
-
- {storage.showSettingsButton && (
- {
- userSettingsAction?.openUserSettings?.();
- }}
- />
- )}
- {res}
-
- );
+ return (
+
+ {storage.showDMButton && (
+ transitionModule?.transitionToGuild?.("@me")}
+ />
+ )}
+ {storage.showSettingsButton && (
+ userSettingsAction?.openUserSettings?.()}
+ />
+ )}
+ {res}
+
+ );
+ } catch {
+ // A shape mismatch here should mean "stock YouBar, no extra buttons" - not a crash.
+ return OriginalRender(...args);
+ }
});
}
diff --git a/plugins/server-drawer/src/patches/createElementIntercept.ts b/shared/lib/createElementIntercept.ts
similarity index 63%
rename from plugins/server-drawer/src/patches/createElementIntercept.ts
rename to shared/lib/createElementIntercept.ts
index 690a1b7..a19b5c5 100644
--- a/plugins/server-drawer/src/patches/createElementIntercept.ts
+++ b/shared/lib/createElementIntercept.ts
@@ -11,8 +11,14 @@ interface PropsIntercept {
replacement: React.ComponentType | null;
}
+interface TypeDetector {
+ predicate: (type: any) => boolean;
+ onDetected: (type: any) => void;
+}
+
const intercepts = new Map, 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 = [];
});
}