diff --git a/plugins/staff-tags/js/patches/details.tsx b/plugins/staff-tags/js/patches/details.tsx index 2ef94db..def479f 100644 --- a/plugins/staff-tags/js/patches/details.tsx +++ b/plugins/staff-tags/js/patches/details.tsx @@ -1,7 +1,6 @@ import { Stores } from '@revenge-mod/discord/flux' import { getModules } from '@revenge-mod/modules/finders' -import { withProps } from '@revenge-mod/modules/finders/filters' -import { after } from '@revenge-mod/patcher' +import { afterJSX } from '@revenge-mod/react/jsx-runtime' import { findInReactFiber } from '@revenge-mod/utils/react' import { fiberFilter } from '../lib/fiber' import { withMemoDefaultName } from '../lib/filters' @@ -9,7 +8,7 @@ import getTag from '../lib/getTag' import { guard } from '../lib/safe' import GradientTag from '../ui/GradientTag' import type { JsonStorage } from '@revenge-mod/json-storage' -import type { FC } from 'react' +import type { FC, ReactElement } from 'react' import type { StaffTagsStorage } from '../lib/types' interface UserRowProps { @@ -17,14 +16,6 @@ interface UserRowProps { user: any } -interface TagComponentProps { - type: number - text: string - textColor: string - backgroundColor: string - verified: boolean -} - /** * Covers member list rows and the profile popout, where the name and tag sit on the same line. * @@ -36,49 +27,59 @@ interface TagComponentProps { * plain `withName('UserRow')` can never match it, since the wrapper object has no `.name` of its * own (only the memo-hidden inner function does). `withMemoDefaultName` (`../lib/filters`) checks * the wrapper's inner `.type.name` instead and matches on the full module namespace, so the - * callback below receives `{default: }`. + * callback below receives `{default: }` - `mod.default` is the exact reference + * Discord's own `createElement(UserRow, ...)` calls use, which is what `afterJSX` needs. * - * Patches the memo wrapper's inner `.type` directly with a single persistent `after()` (same - * pattern `chat.ts` uses on `getTagProperties`), reading `props` fresh from `args[0]` on every - * call - NOT the old per-`createElement` `afterJSX`+`afterRendered`, self-unpatching approach. - * That approach is unsound for memo components specifically: every row instance shares the exact - * same inner render function (memo only produces one), so patching it per-instance still patches - * one shared target, and every currently-stacked self-unpatching hook fires on *any* row's actual - * render call, not just the row that registered it - each hook then unpatches itself regardless - * of whether the `ret` it received actually belonged to its own row. In practice this meant tags - * almost never landed on the correct row (mismatched pairing, silently swallowed by `guard()`), - * and the never-correctly-consumed hooks piled up unbounded across scrolling/re-mounts until the - * sheer patch-chain depth broke rendering outright (confirmed live - the member list going empty - * after normal use, see /root/evals-for-rn). A single persistent patch on the shared function, - * reading each call's own `args[0]`, doesn't have this pairing problem: `args[0]` is always - * correctly paired to whichever row is actually being rendered on that call, same guarantee - * `chat.ts` already relies on for its shared `getTagProperties` patch. + * Does NOT patch the memo wrapper's shared inner `.type` via `@revenge-mod/patcher`'s generic + * `after()` (tried and confirmed live to be unsound - see git history on this file). That's true + * even for a single, non-stacking, persistent `after()` call: wrapping the shared render function + * in `after()`'s Proxy broke member list rendering outright, every time, even on a fully fresh app + * process with nothing else patched. Whatever `after()` does to make a target "patchable" doesn't + * survive contact with this particular shared function. * - * Uses `getModules` (reacts once Discord itself initializes each module) instead of - * `lookupModule` (which force-initializes uninitialized modules right away) - forcing UserRow / - * the Tag module to init during `start()`, before Discord is done booting, is what crashed app - * startup the first time this shipped. + * Instead, `afterJSX(wrapper, el => ...)` intercepts each `createElement(UserRow, props)` call + * (the per-instance element, never the shared object) and does a **plain, unwrapped property + * assignment** - `el.type = patchedRender` - to a single stable function reused for every row + * (defined once, outside the `afterJSX` callback, not a fresh closure per row). `patchedRender` + * manually calls the real inner render function itself (`innerRender(props)`, safe to call + * directly - React only ever needs the dispatcher active, and it is, since `patchedRender` itself + * is invoked *by* React as `el`'s render function), then augments the returned tree. This avoids + * `after()`'s Proxy machinery entirely while still landing a plain, always-callable function + * (never the non-callable memo wrapper) on `element.type`, so it doesn't hit the earlier + * "TypeError: target is not callable" crash either. Reusing the same function reference across + * every row (rather than a fresh closure per element) keeps `element.type` identity stable across + * re-renders, letting React reconcile these rows normally instead of remounting each one. + * + * `UserRow` renders a single `TableRow` whose name+tag live at `ret.props.label` (a `View`) as a + * **prop**, not somewhere reachable by a generic "find array children containing a string" + * search - that mismatch (not the render-output shape itself) is why tags never appeared even + * before the crash: `findInReactFiber` was searching for a shape that doesn't exist in this tree. + * `label.props.children` is `[nameElement, fragment, existingTagElement]` - confirmed live via + * `/root/evals-for-rn` - where `existingTagElement` is Discord's own `BotTag` component with a + * static `.Types` enum and a numeric `type` prop; ordinary members render it already, with + * `type: 0` (an inactive placeholder, not the "BOT" badge that enum value name suggests). Kept + * `existingTag.props?.type !== 0` as a guard against stacking our own tag next to a real built-in + * badge (bot/official/system), but dropped the old "mutate `existingTag`'s props in place" path + * entirely - confirmed live that `BotTag` derives its displayed label purely from the `type` + * enum (via the same module's `getBotLabel`), ignoring any custom `text`/color props entirely, so + * that branch silently did nothing. Always injecting our own plugin-authored `GradientTag` + * instead (confirmed live to actually render custom text/color) is the only path that works; + * `GradientTag` already branches on its own `gradientColor` prop for solid-vs-gradient, so there's + * no need for the old `TagModule` (Discord's `BotTag` module again, same dead end) fallback. + * + * Uses `getModules` (reacts once Discord itself initializes the module) instead of + * `lookupModule` (which force-initializes uninitialized modules right away) - forcing UserRow to + * init during `start()`, before Discord is done booting, is what crashed app startup the first + * time this shipped. * * Every callback below is wrapped in `guard()`: it runs whenever Discord itself renders/ * initializes the matched module, not inside `applyPatches`' try/catch, which only covers the - * synchronous setup calls - an uncaught throw here crashes app startup instead of just skipping - * one row, and (per the above) would otherwise poison the shared render function for every row. - * - * TODO(live-verify): rendered-tree shape (`c?.type?.Types`) still unconfirmed, see eval-for-revenge. + * synchronous setup calls - an uncaught throw here would otherwise skip a row's tag at best, or + * (per the shared-target lesson above) risk destabilizing every row's rendering at worst. */ export default function patchDetails(storage: JsonStorage) { const patches: (() => void)[] = [] - let TagModule: { default?: FC } | undefined - const unsubTagModule = getModules( - withProps('default', 'getBotLabel'), - mod => { - TagModule = mod as { default?: FC } - }, - { skipDefault: true }, - ) - patches.push(unsubTagModule) - const GuildStore = Stores.GuildStore as unknown as { getGuild(id: string | undefined): any } @@ -88,80 +89,54 @@ export default function patchDetails(storage: JsonStorage) { mod => { guard(() => { const wrapper = (mod as { default: { type: FC } }).default - if (typeof wrapper.type !== 'function') return + const innerRender = wrapper.type + if (typeof innerRender !== 'function') return + + // Call through unguarded - if Discord's own render function throws, that's not + // something we caused or can meaningfully recover from here (same rationale as + // `chat.ts`'s `getTagProperties` patch). Only our own augmentation is guarded. + const patchedRender: FC = props => { + const ret = innerRender(props) + + return guard(() => { + const { guildId, user } = props + if (!user) return ret + + const label = (ret as ReactElement)?.props?.label as + | ReactElement + | undefined + if (!label || !Array.isArray(label.props?.children)) return ret + + const existingTag = findInReactFiber( + label as any, + fiberFilter(c => c?.type?.Types), + ) + if (existingTag && existingTag.props?.type !== 0) return ret + + const guild = GuildStore?.getGuild(guildId) + const tag = getTag(storage, guild, undefined, user) + if (!tag) return ret + + const container = label.props as { children: unknown[] } + container.children.push( + , + ) + + return ret + }, ret) + } patches.push( - after(wrapper, 'type', (args, ret) => + afterJSX(wrapper as unknown as FC, el => guard(() => { - const props = args?.[0] as UserRowProps | undefined - if (!props?.user) return ret - - const { guildId, user } = props - - const label = findInReactFiber( - ret as any, - fiberFilter( - c => - Array.isArray(c?.props?.children) && - c.props.children.some( - (ch: any) => - typeof ch === 'string' || - typeof ch?.props?.children === 'string', - ), - ), - ) - if (!label) return ret - - const existingTag = findInReactFiber( - label as any, - fiberFilter(c => c?.type?.Types), - ) - if (existingTag && existingTag.props?.type !== 0) return ret - - const guild = GuildStore?.getGuild(guildId) - const tag = getTag(storage, guild, undefined, user) - if (!tag) return ret - - if (existingTag) { - Object.assign(existingTag.props, { - type: 0, - text: tag.text, - textColor: tag.textColor, - backgroundColor: tag.backgroundColor, - verified: tag.verified, - }) - return ret - } - - const container = label as any - if (!Array.isArray(container.props.children)) { - container.props.children = [container.props.children] - } - - if (tag.gradientColor) { - container.props.children.push( - , - ) - } else if (TagModule?.default) { - const Component = TagModule.default - container.props.children.push( - , - ) - } - - return ret - }, ret), + ;(el as { type: unknown }).type = patchedRender + return el + }, el), ), ) }, undefined) diff --git a/plugins/staff-tags/manifest.json b/plugins/staff-tags/manifest.json index 7991980..9376856 100644 --- a/plugins/staff-tags/manifest.json +++ b/plugins/staff-tags/manifest.json @@ -4,7 +4,7 @@ "name": "Staff Tags", "description": "Adds customizable tags (OWNER, ADMIN, STAFF, MOD, and more) next to members based on their server permissions. Supports per-tag colors, gradients, custom text, and visibility toggles.", "author": "Rosie", - "version": "1.0.8", + "version": "1.0.9", "dependencies": { "revenge.api": { "version": ">=1 <2"