diff --git a/plugins/server-drawer/src/lib/createElementIntercept.ts b/plugins/server-drawer/src/lib/createElementIntercept.ts index e178e0a..8083acd 100644 --- a/plugins/server-drawer/src/lib/createElementIntercept.ts +++ b/plugins/server-drawer/src/lib/createElementIntercept.ts @@ -1,15 +1,10 @@ import { React } from "@vendetta/metro/common"; -import { rawFindAll } from "./rawFind"; +import { rawFind } from "./rawFind"; +import { waitFor } from "./waitFor"; interface Intercept { replacement: React.ComponentType; extraProps?: Record; - /** - * How many ancestor levels above this element are allowed to be collapsed to zero size along - * with it (0 = none). See `COLLAPSE_STYLE` below for why replacing a component with one that - * renders nothing is, on its own, not enough to reclaim its space. - */ - collapseAncestors?: number; } interface PropsIntercept { @@ -23,7 +18,6 @@ interface PropsTransform { } interface TypeDetector { - key: string; predicate: (type: any) => boolean; onDetected: (type: any) => void; persistent: boolean; @@ -33,45 +27,14 @@ const intercepts = new Map, Intercept>(); const propsIntercepts: PropsIntercept[] = []; const propsTransforms: PropsTransform[] = []; let typeDetectors: TypeDetector[] = []; -const detectorKeys = new Set(); let isPatched = false; -/** - * A React element created for something we're hiding, mapped to how many more ancestor levels may - * still be collapsed with it. Elements are plain objects and are created child-first (a parent's - * jsx() call can only run once its children exist), so a mark placed on a child is always already - * present by the time the parent's own call is intercepted - which is what makes walking the - * collapse *upward* possible at element-creation time at all. - */ -const collapseMarks = new WeakMap(); - -/** - * Zeroes an element out in every direction a parent could have sized it. `display: "none"` alone - * is enough on Yoga, but the explicit width/flex zeroes also cover a container that sets its - * child's size through a style array further up, and `overflow: hidden` stops anything absolutely - * positioned inside from still painting. - */ -const COLLAPSE_STYLE = { - display: "none" as const, - width: 0, - minWidth: 0, - maxWidth: 0, - flexGrow: 0, - flexShrink: 0, - flexBasis: 0, - margin: 0, - padding: 0, - borderWidth: 0, - overflow: "hidden" as const, -}; - export function registerIntercept( original: React.ComponentType, replacement: React.ComponentType, extraProps?: Record, - options?: { collapseAncestors?: number }, ) { - intercepts.set(original, { replacement, extraProps, collapseAncestors: options?.collapseAncestors }); + intercepts.set(original, { replacement, extraProps }); } /** @@ -116,12 +79,6 @@ export function registerPropsTransform(predicate: (props: any) => boolean, trans * 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. * - * `key` deduplicates registrations. index.ts retries any patch that hasn't landed yet every 200ms - * for ~10s, and each of those retries used to append another copy of the same detector - up to ~50 - * duplicates per patch, every one of them re-tested against the `type` of every element created - * anywhere in the app, during the exact window (cold boot) where the drawer is trying to appear - * quickly. Registering under a key makes a retry a no-op instead. - * * `persistent` (default false, one-shot): keeps watching and firing `onDetected` again for every * later match too, instead of stopping after the first. Needed when the component's own type * reference isn't a stable session-long singleton - confirmed live for GuildsBar, whose reference @@ -130,24 +87,16 @@ export function registerPropsTransform(predicate: (props: any) => boolean, trans * registered for the old, now-abandoned reference while the new one renders unmodified. */ 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); + typeDetectors.push({ predicate, onDetected, persistent: options?.persistent ?? false }); } function runTypeDetectors(type: any) { if (typeDetectors.length === 0) return; - - let consumed = false; + const remaining: TypeDetector[] = []; for (const detector of typeDetectors) { let matched = false; try { @@ -155,60 +104,22 @@ function runTypeDetectors(type: any) { } catch { // A bad predicate shouldn't block every other detector or every element from rendering. } - if (!matched) continue; - - try { - detector.onDetected(type); - } catch { - // Same - a detector's own handler throwing shouldn't take anything else down. + if (matched) { + try { + detector.onDetected(type); + } catch { + // Same - a detector's own handler throwing shouldn't take anything else down. + } + if (detector.persistent) remaining.push(detector); + } else { + remaining.push(detector); } - if (!detector.persistent) consumed = true; } - - // Only rebuild the list when a one-shot detector actually fired. The previous version rebuilt - // it on every single element creation app-wide, forever, because the GuildsBar detector is - // persistent and so the list is never empty - an allocation per element on the hottest path in - // the entire app. - if (consumed) { - typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired); - } -} - -// Marking is done out-of-band so the filter above stays a pure predicate. -declare module "./createElementIntercept" {} -interface TypeDetector { justFired?: boolean } - -/** - * Returns the collapse depth a parent element should inherit from its children, or 0 for "leave - * this element alone." - * - * Only collapses a parent that has this element as its *only* child. That guard is the whole - * safety story here: a wrapper whose sole content is the server rail is, by definition, only there - * to size and position the rail, so zeroing it is correct. A container that also holds the channel - * list or the rest of the guilds screen has other children, propagation stops there, and the rest - * of the UI is untouched. - */ -function inheritedCollapseDepth(props: any): number { - const children = props?.children; - if (children == null) return 0; - - if (Array.isArray(children)) { - let only: any = null; - for (const child of children) { - if (child == null || child === false) continue; - if (only) return 0; // more than one real child - not a rail-only wrapper - only = child; - } - if (!only) return 0; - return (typeof only === "object" ? collapseMarks.get(only) : 0) ?? 0; - } - - if (typeof children !== "object") return 0; - return collapseMarks.get(children) ?? 0; + 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; collapse?: number } | null | undefined { +function resolveReplacement(type: any, props: any): { type: any; props: any } | null | undefined { runTypeDetectors(type); let effectiveProps = props; @@ -238,23 +149,7 @@ function resolveReplacement(type: any, props: any): { type: any; props: any; col const entry = intercepts.get(type); if (entry) { - return { - type: entry.replacement, - props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps, - collapse: entry.collapseAncestors, - }; - } - - // Nothing to swap here, but if this element's only child is one we've already collapsed, this - // element is a wrapper that exists purely to hold it - zero it out too and pass the remaining - // budget further up. - const inherited = inheritedCollapseDepth(effectiveProps); - if (inherited > 0) { - return { - type, - props: { ...effectiveProps, style: [effectiveProps?.style, COLLAPSE_STYLE] }, - collapse: inherited - 1, - }; + return { type: entry.replacement, props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps }; } if (effectiveProps !== props) { @@ -264,23 +159,6 @@ function resolveReplacement(type: any, props: any): { type: any; props: any; col return undefined; } -function makeWrapper(orig: Function, thisArg: any) { - return function (this: any, type: any, props: any, ...rest: any[]) { - const resolved = resolveReplacement(type, props); - if (resolved === null) return null; - - if (resolved) { - const el = orig.call(thisArg ?? this, resolved.type, resolved.props, ...rest); - if (resolved.collapse && resolved.collapse > 0 && el && typeof el === "object") { - collapseMarks.set(el, resolved.collapse); - } - return el; - } - - return orig.call(thisArg ?? this, type, props, ...rest); - }; -} - export function patchCreateElement(cleanups: (() => void)[]) { if (isPatched) return; @@ -290,82 +168,58 @@ export function patchCreateElement(cleanups: (() => void)[]) { return; } - const patchedCreateElement = makeWrapper(origCreateElement, React); + const patchedCreateElement = function (type: any, props: any, ...rest: any[]) { + const resolved = resolveReplacement(type, props); + if (resolved === null) return null; + if (resolved) return origCreateElement.call(React, resolved.type, resolved.props, ...rest); + return origCreateElement.call(React, type, props, ...rest); + }; + Object.assign(patchedCreateElement, origCreateElement); React.createElement = patchedCreateElement as typeof React.createElement; isPatched = true; // Modern React/RN builds (Discord's included) compile JSX through the automatic runtime - // (jsx/jsxs/jsxDEV from react/jsx-runtime) instead of React.createElement - patching only - // createElement misses effectively every one of Discord's own render calls. Found by shape + // (jsx/jsxs from react/jsx-runtime) instead of React.createElement - patching only + // 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. // - // Two changes over the previous version, both of which were causing the drawer to show up only - // sometimes: - // - // 1. ALL matching runtime modules get patched, not just the first one rawFind returned. A - // bundle can hold more than one copy of the runtime, and any chunk importing a copy we - // didn't patch renders completely uninterceptable elements. - // 2. The scan keeps running instead of stopping at the first success. Metro registers modules - // lazily, so a runtime copy belonging to a chunk that hasn't been required yet at boot - // simply does not exist to be found at that moment - the old one-shot waitFor resolved off - // the first copy and never looked again, permanently missing every copy registered later. + // The jsx-runtime lookup is retried (not a single one-shot rawFind) because if that module + // hasn't been required yet at the exact moment this runs - very early in boot, when a plugin's + // onLoad first calls this - jsx/jsxs would never get patched for the rest of the session at + // all, silently. Since Discord renders almost everything through jsx/jsxs and not + // createElement, that would make every registerTypeDetector/registerPropsIntercept consumer's + // patch a near-total no-op whenever that race was lost. const restoreJsx: (() => void)[] = []; - const patchedRuntimes = new WeakSet(); - - const scan = () => { - const runtimes = rawFindAll( - (m: any) => typeof m?.jsx === "function" && typeof m?.jsxs === "function" && "Fragment" in m, - ); - - for (const runtime of runtimes) { - if (patchedRuntimes.has(runtime)) continue; - patchedRuntimes.add(runtime); - - for (const key of ["jsx", "jsxs", "jsxDEV"] as const) { - const orig = runtime[key]; + const jsxHandle = waitFor( + () => rawFind((m: any) => typeof m?.jsx === "function" && typeof m?.jsxs === "function" && "Fragment" in m), + (jsxRuntime: any) => { + for (const key of ["jsx", "jsxs"] as const) { + const orig = jsxRuntime[key]; if (typeof orig !== "function") continue; - runtime[key] = makeWrapper(orig, undefined); - restoreJsx.push(() => { runtime[key] = orig; }); + jsxRuntime[key] = function (type: any, props: any, ...rest: any[]) { + const resolved = resolveReplacement(type, props); + if (resolved === null) return null; + if (resolved) return orig.call(this, resolved.type, resolved.props, ...rest); + return orig.call(this, type, props, ...rest); + }; + restoreJsx.push(() => { jsxRuntime[key] = orig; }); } } - }; - - scan(); - - // 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 | undefined = setInterval(function tick() { - scan(); - if (++ticks === 50 && timer) { // ~5s at 100ms - clearInterval(timer); - timer = setInterval(() => { - scan(); - if (++ticks >= 75 && timer) { // + ~25s at 1s - clearInterval(timer); - timer = undefined; - } - }, 1000); - } - }, 100); + ); cleanups.push(() => { - if (timer) clearInterval(timer); - timer = undefined; + jsxHandle.cancel(); if (isPatched) { React.createElement = origCreateElement; isPatched = false; } restoreJsx.forEach((fn) => fn()); - restoreJsx.length = 0; intercepts.clear(); propsIntercepts.length = 0; propsTransforms.length = 0; typeDetectors = []; - detectorKeys.clear(); }); } diff --git a/plugins/server-drawer/src/lib/rawFind.ts b/plugins/server-drawer/src/lib/rawFind.ts index 740bfcc..178a371 100644 --- a/plugins/server-drawer/src/lib/rawFind.ts +++ b/plugins/server-drawer/src/lib/rawFind.ts @@ -39,91 +39,6 @@ export function rawFind(predicate: (exports: any) => boolean): T | unde return undefined; } -export function rawFindAll(predicate: (exports: any) => boolean): T[] { - const modules = window?.modules; - if (!modules) return []; - - const results: T[] = []; - const seen = new Set(); - - for (const id in modules) { - const def = modules[id]; - if (!def?.isInitialized) continue; - - const exports = def.publicModule?.exports; - if (!exports) continue; - - try { - if (predicate(exports) && !seen.has(exports)) { - results.push(exports); - seen.add(exports); - } - const dflt = exports.default; - if (dflt != null && predicate(dflt) && !seen.has(dflt)) { - results.push(dflt); - seen.add(dflt); - } - } catch { - // Ignore predicate errors and continue scanning. - } - } - - return results; -} - -/** - * Async batched scan: does the same as rawFindAll but yields between batches so the UI thread - * isn't blocked for long. Returns a Promise of the matching exports array. - */ -export async function rawFindAllAsync( - predicate: (exports: any) => boolean, - opts?: { batchSize?: number; pauseMs?: number; maxProcessed?: number }, -): Promise { - const modules = window?.modules; - if (!modules) return []; - - const keys = Object.keys(modules); - const batchSize = opts?.batchSize ?? 200; - const pauseMs = opts?.pauseMs ?? 0; // 0 uses setTimeout(...,0) to yield - const maxProcessed = opts?.maxProcessed ?? Infinity; - - const results: T[] = []; - const seen = new Set(); - let processed = 0; - - for (let i = 0; i < keys.length; i += batchSize) { - const end = Math.min(i + batchSize, keys.length); - for (let j = i; j < end; j++) { - if (processed++ >= maxProcessed) return results; - const def = modules[keys[j]]; - if (!def?.isInitialized) continue; - const exports = def.publicModule?.exports; - if (!exports) continue; - try { - if (predicate(exports) && !seen.has(exports)) { - results.push(exports as any); - seen.add(exports); - } - const dflt = exports.default; - if (dflt != null && predicate(dflt) && !seen.has(dflt)) { - results.push(dflt as any); - seen.add(dflt); - } - } catch { - // ignore - } - } - // yield to event loop so UI can update - if (pauseMs === 0) { - await new Promise((r) => setTimeout(r, 0)); - } else if (pauseMs > 0) { - await new Promise((r) => setTimeout(r, pauseMs)); - } - } - - return results; -} - export function rawFindByTypeName(name: string): T | undefined { return rawFind((m) => m?.name === name || m?.displayName === name || m?.type?.name === name || m?.type?.displayName === name); } diff --git a/plugins/server-drawer/src/patches/hideGuildsBar.tsx b/plugins/server-drawer/src/patches/hideGuildsBar.tsx index a3dd5d2..8b35e0f 100644 --- a/plugins/server-drawer/src/patches/hideGuildsBar.tsx +++ b/plugins/server-drawer/src/patches/hideGuildsBar.tsx @@ -10,25 +10,9 @@ const TAG = "[ServerDrawer]"; // capture point inside ServerDrawerSheet.tsx itself, which is guaranteed to mount whenever the // drawer is visibly showing). Kept here too as a second chance, harmless either way. function Nothing() { - // Use the same aggressive collapse style as createElementIntercept's COLLAPSE_STYLE so that - // the replaced element itself takes up zero space while still mounting a ref for fiber capture. - // Keeping a mounted element with zero size lets us capture the fiber without leaving a visible - // 1px gap on the left rail. return React.createElement(View, { ref: captureFiberRef, - style: { - display: "none", - width: 0, - minWidth: 0, - maxWidth: 0, - flexGrow: 0, - flexShrink: 0, - flexBasis: 0, - margin: 0, - padding: 0, - borderWidth: 0, - overflow: "hidden", - }, + style: { width: 1, height: 1 }, }); } @@ -65,11 +49,8 @@ function isGuildsBar(type: any): boolean { * whenever the rail would normally repaint - renders nothing instead. */ export function patchHideGuildsBar(cleanups: (() => void)[]): boolean { - // Use a stable string key so duplicate registrations from retry loops are deduped correctly. - registerTypeDetector("ServerDrawer.HideGuildsBar", isGuildsBar, (realGuildsBar) => { - // Ask createElementIntercept to collapse a few ancestor levels as well so wrapper - // containers around the rail don't leave a leftover gap. - registerIntercept(realGuildsBar, Nothing, undefined, { collapseAncestors: 3 }); + registerTypeDetector(isGuildsBar, (realGuildsBar) => { + registerIntercept(realGuildsBar, Nothing); console.log(TAG, "PATCH: found a real GuildsBar reference, now rendering nothing"); }, { persistent: true }); cleanups.push(() => {