diff --git a/plugins/you-bar-plus/src/patches/youBarButtons.tsx b/plugins/you-bar-plus/src/patches/youBarButtons.tsx index 7136b5f..1cc3553 100644 --- a/plugins/you-bar-plus/src/patches/youBarButtons.tsx +++ b/plugins/you-bar-plus/src/patches/youBarButtons.tsx @@ -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; diff --git a/shared/lib/forceRerender.ts b/shared/lib/forceRerender.ts new file mode 100644 index 0000000..9ceff15 --- /dev/null +++ b/shared/lib/forceRerender.ts @@ -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 | 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; +}