YouBar+: force the buttons to actually appear instead of waiting on luck

This commit is contained in:
everestmcarthur 2026-08-01 19:50:43 -04:00
parent a25ce4bfb0
commit 8b1eb71674
2 changed files with 109 additions and 3 deletions

View file

@ -5,9 +5,22 @@ import { storage } from "@vendetta/plugin";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { waitFor } from "@shared/lib/waitFor";
import { rawFindByTypeName } from "@shared/lib/rawFind";
import { forceRerender } from "@shared/lib/forceRerender";
/** Lets the Settings screen force the patched YouBar to re-render right after a toggle changes. */
export let updateYouBar = () => {};
let internalForceUpdate: (() => void) | null = null;
let patchedComponent: any = null;
/**
* Lets the Settings screen force the patched YouBar to re-render right after a toggle changes.
* Prefers the cheap in-tree hook update once our wrapper has actually rendered at least once under
* the patch; falls back to forceRerender's fiber-walk otherwise, since a memoized component that
* hasn't rendered under the patch yet has no hook state to update through (see forceRerender.ts for
* why that fallback is needed at all).
*/
export function updateYouBar(): void {
if (internalForceUpdate) internalForceUpdate();
else if (patchedComponent) forceRerender(patchedComponent);
}
/**
* `YouBarNotificationsButton` isn't guaranteed to be registered in Metro yet at the exact moment
@ -29,12 +42,20 @@ export default function patchYouBarButtons(): () => void {
() => rawFindByTypeName("YouBarNotificationsButton"),
(YouBarNotificationsButton) => {
unpatch = applyButtonPatch(YouBarNotificationsButton);
patchedComponent = YouBarNotificationsButton;
// Finding the module and patching it doesn't make anything visible on its own - this
// component is React.memo'd with a stable prop, so once mounted it can go the rest of
// the session without ever rendering again on its own (see forceRerender.ts). Force
// one pass right now instead of waiting on luck for the buttons to actually appear.
forceRerender(YouBarNotificationsButton);
}
);
return () => {
handle.cancel();
unpatch();
internalForceUpdate = null;
patchedComponent = null;
};
}
@ -49,7 +70,7 @@ function applyButtonPatch(YouBarNotificationsButton: any): () => void {
return instead("type", YouBarNotificationsButton, (args: any[], OriginalRender: (...a: any[]) => any) => {
try {
const [, forceUpdate] = React.useReducer((x: number) => ~x, 0);
updateYouBar = () => forceUpdate();
internalForceUpdate = () => forceUpdate();
const res = OriginalRender(...args);
if (!res?.props?.children) return res;

View file

@ -0,0 +1,85 @@
declare const window: any;
// The actual reason YouBar+'s buttons only ever showed up after a full reload: YouBar's own
// button is wrapped in React.memo with the default shallow-prop comparator, and it only ever
// receives one stable boolean prop. Once it mounts, React's own updateMemoComponent bailout means
// it will *never* run its render function again - regardless of how often anything around it
// re-renders - unless something upstream schedules real work that reaches this exact fiber. That
// makes patching `.type` alone invisible until pure luck (an unrelated future re-render) happens
// to touch it, which on a live device can be "eventually" or "never in this session" - matching
// exactly what looked like "needs 2 reloads."
//
// This does two things, both best-effort and safe to call redundantly:
// 1. If `target` is a memo wrapper, permanently override its `compare` to always report "props
// changed" - so from this point on, any future reconciliation pass that *does* reach this
// fiber for any reason will never bail out again.
// 2. Force one reconciliation pass to reach it *right now* instead of waiting on (1) alone, via
// React's own DevTools fiber-root registry - which every RN app populates purely because React
// itself reports its roots there, with no real inspector needing to be attached (confirmed
// present in Discord's own bundle via `__REACT_DEVTOOLS_GLOBAL_HOOK__`). Once the live fiber
// for `target` is found, this walks up to the nearest real class-component ancestor and calls
// its public, ordinary `forceUpdate()` - a genuinely scheduled update, so React correctly
// descends all the way down through every bailout on the way to us, instead of skipping this
// part of the tree entirely as "nothing pending here."
// If either step's assumptions don't hold on a given build, it just silently no-ops - the existing
// patch and poll are still in place, so the worst case is unchanged from today (shows up on the
// next natural re-render or reload), never a crash.
export function forceRerender(target: any): void {
try {
if (target && typeof target === "object" && "compare" in target) {
target.compare = () => false;
}
} catch {
// Best effort - a shape mismatch here just means no permanent bailout override.
}
try {
const hook = window?.__REACT_DEVTOOLS_GLOBAL_HOOK__;
const renderers = hook?.renderers;
if (!hook?.getFiberRoots || !renderers?.keys) return;
for (const rendererID of renderers.keys()) {
const roots: Set<any> | undefined = hook.getFiberRoots(rendererID);
if (!roots) continue;
for (const root of roots) {
const fiber = findFiberByType(root.current, target);
if (fiber && bump(fiber)) return;
}
}
} catch {
// Best effort - no devtools hook, no fiber roots, or an internal shape change.
}
}
/** Iterative (not recursive) so an unusually deep RN fiber tree can't blow the JS call stack. */
function findFiberByType(root: any, target: any): any {
const stack = root ? [root] : [];
let guard = 0;
while (stack.length) {
if (++guard > 200_000) return null; // Pathological tree - bail rather than hang.
const fiber = stack.pop();
if (!fiber) continue;
if (fiber.type === target || fiber.elementType === target) return fiber;
if (fiber.child) stack.push(fiber.child);
if (fiber.sibling) stack.push(fiber.sibling);
}
return null;
}
/** Walks up from `fiber` to the nearest class component and calls its real forceUpdate(). */
function bump(fiber: any): boolean {
let node = fiber;
while (node) {
if (typeof node.stateNode?.forceUpdate === "function") {
node.stateNode.forceUpdate();
return true;
}
node = node.return;
}
return false;
}