YouBar+: force the buttons to actually appear instead of waiting on luck
This commit is contained in:
parent
a25ce4bfb0
commit
8b1eb71674
2 changed files with 109 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
85
shared/lib/forceRerender.ts
Normal file
85
shared/lib/forceRerender.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue