Key Inspector: add an eval tool; forceRerender: switch to the navigation-ref nudge

This commit is contained in:
everestmcarthur 2026-08-01 20:10:56 -04:00
parent 7050778072
commit 1556fdb56b
3 changed files with 160 additions and 56 deletions

View file

@ -0,0 +1,85 @@
import { findByProps, findByName, find } from "@vendetta/metro";
import { React, ReactNative, FluxDispatcher } from "@vendetta/metro/common";
import { rawFind, rawFindByTypeName, rawFindByProps, rawFindByName, rawFindByStoreName } from "@shared/lib/rawFind";
declare const window: any;
function safeStringify(value: any): string {
if (value === undefined) return "undefined";
if (value === null) return "null";
if (typeof value === "function") return `[Function: ${value.name || "anonymous"}]`;
if (typeof value !== "object") return String(value);
const seen = new WeakSet();
try {
return JSON.stringify(
value,
(_key, val) => {
if (typeof val === "function") return `[Function: ${val.name || "anonymous"}]`;
if (typeof val === "symbol") return val.toString();
if (typeof val === "object" && val !== null) {
if (seen.has(val)) return "[Circular]";
seen.add(val);
}
return val;
},
2
);
} catch {
// Most live Discord/React objects (fibers, class instances, refs) have circular structure
// or getters that throw - falling back to a flat key listing still tells you something
// useful instead of just "[object Object]".
try {
return `[unserializable object, keys: ${Object.keys(value).join(", ")}]`;
} catch (e) {
return `[unserializable, and Object.keys threw: ${e}]`;
}
}
}
/**
* Runs arbitrary code typed into the Eval box, with this repo's own lookup helpers already in
* scope - built so an on-device assumption behind a fix (does this property exist, does this call
* behave the way decompiled source suggested) can be checked in seconds instead of a full
* build/push/update/test cycle. The code runs as the body of an async function, so a bare
* expression, a `return ...`, or `await`-ing something all work. Never runs anything on its own -
* only what's typed in and manually run here.
*/
export async function runEval(code: string): Promise<string> {
try {
const fn = new Function(
"findByProps",
"findByName",
"find",
"rawFind",
"rawFindByTypeName",
"rawFindByProps",
"rawFindByName",
"rawFindByStoreName",
"React",
"ReactNative",
"FluxDispatcher",
"window",
`return (async () => {\n${code}\n})();`
);
const result = await fn(
findByProps,
findByName,
find,
rawFind,
rawFindByTypeName,
rawFindByProps,
rawFindByName,
rawFindByStoreName,
React,
ReactNative,
FluxDispatcher,
window
);
return safeStringify(result);
} catch (e) {
return `Error: ${e instanceof Error ? `${e.message}\n${e.stack ?? ""}` : String(e)}`;
}
}

View file

@ -8,6 +8,7 @@ import PrimaryButton from "@shared/ui/PrimaryButton";
import { runAllChecks, formatReport } from "../lib/checks";
import { dumpVendettaApiTree } from "../lib/apiTree";
import { runYouBarDiagnostics } from "../lib/youBarDiagnostics";
import { runEval } from "../lib/evalTool";
const { View, Text } = ReactNative;
const { FormSection, FormInput } = Forms;
@ -59,6 +60,57 @@ function YouBarDiagnosticsSection() {
);
}
function EvalSection() {
const [code, setCode] = React.useState("");
const [result, setResult] = React.useState<string | null>(null);
const [running, setRunning] = React.useState(false);
const run = async () => {
if (!code.trim() || running) return;
setRunning(true);
try {
const output = await runEval(code);
setResult(output);
clipboard.setString(output);
showToast("Result copied", undefined);
} finally {
setRunning(false);
}
};
return (
<FormSection title="Eval">
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
<FormInput
title="Code"
placeholder={'e.g. return findByProps("getRootNavigationRef")'}
value={code}
onChange={setCode}
multiline
/>
<PrimaryButton
label={running ? "Running..." : "Run & copy result"}
style={{ marginTop: 8 }}
disabled={running}
onPress={run}
/>
{result != null && (
<Text style={{ marginTop: 8, fontSize: 12.5, opacity: 0.85 }} selectable>
{result.length > 2000 ? `${result.slice(0, 2000)}\n... (truncated, full result copied)` : result}
</Text>
)}
<Text style={{ marginTop: 8, fontSize: 12.5, opacity: 0.7 }}>
Runs as the body of an async function - a bare expression, `return ...`, or
`await ...` all work. findByProps/findByName/find, the raw* passive variants
(rawFind/rawFindByTypeName/rawFindByProps/rawFindByName/rawFindByStoreName),
React, ReactNative, FluxDispatcher, and window are already in scope. Nothing runs
until you tap the button.
</Text>
</View>
</FormSection>
);
}
function ApiTreeSection() {
return (
<FormSection title="Full Vendetta API tree">
@ -154,6 +206,7 @@ export default function Settings() {
<SettingsScaffold>
<FullScanSection />
<YouBarDiagnosticsSection />
<EvalSection />
<ApiTreeSection />
<ManualSearchSection />
<NoteBox>

View file

@ -1,3 +1,5 @@
import { findByProps } from "@vendetta/metro";
declare const window: any;
// The actual reason YouBar+'s buttons only ever showed up after a full reload: YouBar's own
@ -9,20 +11,22 @@ declare const window: any;
// to touch it, which on a live device can be "eventually" or "never in this session" - matching
// exactly what looked like "needs 2 reloads."
//
// The first version of this file tried to force that update by walking React's DevTools fiber-root
// registry (__REACT_DEVTOOLS_GLOBAL_HOOK__). Confirmed via Key Inspector on a real device that
// hook simply doesn't exist on this Discord build at all - React never gets a chance to register
// with it, so that whole approach was silently doing nothing. This version instead uses a real,
// public react-navigation API that Key Inspector also confirmed works: getRootNavigationRef().
//
// 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."
// 1. If `target` is a memo wrapper, permanently override its `compare` so any future
// reconciliation pass that reaches it never bails out again.
// 2. Force a pass to reach it *right now* by calling setParams() on the currently-focused route
// with its own unchanged params. That's a genuine react-navigation state update (a new state
// object, not a no-op) - and unlike a plain prop diff, navigation state is read through
// context, which React always propagates through memoized descendants that consume it, instead
// of letting them silently bail like a prop-only update would.
// 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
// patch and poll are still in place, so the worst case is unchanged from before (shows up on the
// next natural re-render or reload), never a crash.
export function forceRerender(target: any): void {
try {
@ -34,52 +38,14 @@ export function forceRerender(target: any): void {
}
try {
const hook = window?.__REACT_DEVTOOLS_GLOBAL_HOOK__;
const renderers = hook?.renderers;
if (!hook?.getFiberRoots || !renderers?.keys) return;
const navRef = findByProps("getRootNavigationRef")?.getRootNavigationRef?.();
if (!navRef?.isReady?.()) 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;
}
const route = navRef.getCurrentRoute?.();
if (route && typeof navRef.setParams === "function") {
navRef.setParams(route.params ?? {});
}
} catch {
// Best effort - no devtools hook, no fiber roots, or an internal shape change.
// Best effort - no navigation ref yet, 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;
}