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 = []; }); }