From 3b36744b08e441e6067745f5710ab2ed41538daf Mon Sep 17 00:00:00 2001 From: Rosie Val Date: Fri, 7 Aug 2026 00:36:13 -0400 Subject: [PATCH] ServerDrawer: fix init race and GuildsBar hide --- .../src/lib/createElementIntercept.ts | 248 ++++++++++++++---- plugins/server-drawer/src/lib/rawFind.ts | 85 ++++++ .../src/patches/contentPatch.tsx | 4 +- .../src/patches/hideGuildsBar.tsx | 25 +- 4 files changed, 310 insertions(+), 52 deletions(-) diff --git a/plugins/server-drawer/src/lib/createElementIntercept.ts b/plugins/server-drawer/src/lib/createElementIntercept.ts index 8083acd..9a6ee8b 100644 --- a/plugins/server-drawer/src/lib/createElementIntercept.ts +++ b/plugins/server-drawer/src/lib/createElementIntercept.ts @@ -1,10 +1,15 @@ import { React } from "@vendetta/metro/common"; -import { rawFind } from "./rawFind"; -import { waitFor } from "./waitFor"; +import { rawFindAllAsync } from "./rawFind"; 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 { @@ -18,6 +23,7 @@ interface PropsTransform { } interface TypeDetector { + key: string; predicate: (type: any) => boolean; onDetected: (type: any) => void; persistent: boolean; @@ -27,14 +33,45 @@ 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 }); + intercepts.set(original, { replacement, extraProps, collapseAncestors: options?.collapseAncestors }); } /** @@ -79,6 +116,12 @@ 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 @@ -87,16 +130,24 @@ 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 }, ) { - typeDetectors.push({ predicate, onDetected, persistent: options?.persistent ?? false }); + 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); } function runTypeDetectors(type: any) { if (typeDetectors.length === 0) return; - const remaining: TypeDetector[] = []; + + let consumed = false; for (const detector of typeDetectors) { let matched = false; try { @@ -104,22 +155,60 @@ function runTypeDetectors(type: any) { } 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. - } - if (detector.persistent) remaining.push(detector); - } else { - remaining.push(detector); + if (!matched) continue; + + try { + detector.onDetected(type); + } catch { + // Same - a detector's own handler throwing shouldn't take anything else down. } + if (!detector.persistent) consumed = true; } - typeDetectors = remaining; + + // 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; } /** 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 { +function resolveReplacement(type: any, props: any): { type: any; props: any; collapse?: number } | null | undefined { runTypeDetectors(type); let effectiveProps = props; @@ -149,7 +238,23 @@ function resolveReplacement(type: any, props: any): { type: any; props: any } | const entry = intercepts.get(type); if (entry) { - return { type: entry.replacement, props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps }; + 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, + }; } if (effectiveProps !== props) { @@ -159,6 +264,23 @@ function resolveReplacement(type: any, props: any): { type: any; props: any } | 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 ?? null, ...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 ?? null, ...rest); + }; +} + export function patchCreateElement(cleanups: (() => void)[]) { if (isPatched) return; @@ -168,58 +290,90 @@ export function patchCreateElement(cleanups: (() => void)[]) { return; } - 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); - }; - + const patchedCreateElement = makeWrapper(origCreateElement, React); 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 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/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/Fragment together), not by name, for the same reason everything else in this file // avoids name-based lookups. // - // 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. + // 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. const restoreJsx: (() => void)[] = []; - 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; + const patchedRuntimes = new WeakSet(); + let scanning = false; - 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; }); + const scan = () => { + if (scanning) return; + scanning = true; + + rawFindAllAsync( + (m: any) => typeof m?.jsx === "function" && typeof m?.jsxs === "function" && "Fragment" in m, + ).then((runtimes) => { + 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]; + if (typeof orig !== "function") continue; + + runtime[key] = makeWrapper(orig, undefined); + restoreJsx.push(() => { runtime[key] = orig; }); + } } + }).catch((e: any) => { + console.error("[ServerDrawer] rawFindAllAsync error", e); + }).finally(() => { + scanning = false; + }); + }; + + 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(() => { + 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(() => { - jsxHandle.cancel(); + if (timer) clearInterval(timer); + timer = undefined; 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 178a371..740bfcc 100644 --- a/plugins/server-drawer/src/lib/rawFind.ts +++ b/plugins/server-drawer/src/lib/rawFind.ts @@ -39,6 +39,91 @@ 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/contentPatch.tsx b/plugins/server-drawer/src/patches/contentPatch.tsx index 06fa6dc..c9802eb 100644 --- a/plugins/server-drawer/src/patches/contentPatch.tsx +++ b/plugins/server-drawer/src/patches/contentPatch.tsx @@ -45,7 +45,7 @@ function ServerDrawerSheetWrapper() { * when the race is won, and still shows correctly even when it's lost. */ export function patchExpanded(cleanups: (() => void)[]): boolean { - registerTypeDetector(isNamed("QuestDockContentExpanded"), (real) => { + registerTypeDetector("ServerDrawer.Expanded", isNamed("QuestDockContentExpanded"), (real) => { registerIntercept(real, ServerDrawerSheetWrapper); console.log(TAG, "PATCH: QuestDockContentExpanded replaced (type detector)"); }); @@ -65,7 +65,7 @@ export function patchExpanded(cleanups: (() => void)[]): boolean { } export function patchEmpty(name: string, cleanups: (() => void)[]): boolean { - registerTypeDetector(isNamed(name), (real) => { + registerTypeDetector(`ServerDrawer.Empty.${name}`, isNamed(name), (real) => { registerIntercept(real, function EmptyPatch() { return null; }); console.log(TAG, `PATCH: ${name} replaced (type detector)`); }); diff --git a/plugins/server-drawer/src/patches/hideGuildsBar.tsx b/plugins/server-drawer/src/patches/hideGuildsBar.tsx index 8b35e0f..a3dd5d2 100644 --- a/plugins/server-drawer/src/patches/hideGuildsBar.tsx +++ b/plugins/server-drawer/src/patches/hideGuildsBar.tsx @@ -10,9 +10,25 @@ 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: { width: 1, height: 1 }, + style: { + display: "none", + width: 0, + minWidth: 0, + maxWidth: 0, + flexGrow: 0, + flexShrink: 0, + flexBasis: 0, + margin: 0, + padding: 0, + borderWidth: 0, + overflow: "hidden", + }, }); } @@ -49,8 +65,11 @@ function isGuildsBar(type: any): boolean { * whenever the rail would normally repaint - renders nothing instead. */ export function patchHideGuildsBar(cleanups: (() => void)[]): boolean { - registerTypeDetector(isGuildsBar, (realGuildsBar) => { - registerIntercept(realGuildsBar, Nothing); + // 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 }); console.log(TAG, "PATCH: found a real GuildsBar reference, now rendering nothing"); }, { persistent: true }); cleanups.push(() => {