From e9000107f603ac42ce28afed41e62b5b5e63bfc8 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Sun, 9 Aug 2026 00:25:29 -0400 Subject: [PATCH] staff-tags: fix member list tags never landing, bump to 1.0.8 UserRow is React.memo()-wrapped, so every row shares one inner render function. The old afterJSX+afterRendered approach registered a new self-unpatching hook on that shared function per row; since every currently-stacked hook fires on any row's actual render (not just its own), pairing broke almost immediately and hooks piled up unbounded, eventually breaking member list rendering outright (confirmed live). Replaced with a single persistent after() patch on the shared inner render function, reading props fresh from args[0] each call - same pattern chat.ts already uses successfully for getTagProperties. --- plugins/staff-tags/js/lib/afterRendered.ts | 15 +- plugins/staff-tags/js/patches/details.tsx | 165 +++++++++++---------- plugins/staff-tags/manifest.json | 2 +- 3 files changed, 101 insertions(+), 81 deletions(-) diff --git a/plugins/staff-tags/js/lib/afterRendered.ts b/plugins/staff-tags/js/lib/afterRendered.ts index 6ec050c..4b4d0a0 100644 --- a/plugins/staff-tags/js/lib/afterRendered.ts +++ b/plugins/staff-tags/js/lib/afterRendered.ts @@ -21,6 +21,16 @@ import type { FC, ReactElement, ReactNode } from 'react' * component (`updateSimpleMemoComponent` calls the inner function, not the wrapper), and leaving * the wrapper's own shape (`$$typeof`/`compare`) untouched keeps React's own type detection * correct. + * + * CAUTION: only call this once per distinct memo-wrapped type, ever. The memo wrapper's inner + * `.type` is shared by every instance of that component (memo only produces one render function + * total), not fresh per instance the way a plain element's `.type` is - so calling this from + * something that re-registers on every render (e.g. an `afterJSX` callback firing once per + * `createElement`) stacks a new self-unpatching hook onto that same shared function each time, + * and each stacked hook fires on *any* instance's render, not just the one that registered it. + * That mismatch broke tag placement in the member list for a long time (see git history on + * `patches/details.tsx`) - `patches/details.tsx` now patches the memo case directly with a single + * persistent `@revenge-mod/patcher` `after()` instead of going through this helper. */ export const afterRendered = ( element: ReactElement>, @@ -32,10 +42,7 @@ export const afterRendered = ( } const isMemoWrapped = typeof element.type !== 'function' && typeof type?.type === 'function' - const target = (isMemoWrapped ? type : element) as Record< - 'type', - FC - > + const target = (isMemoWrapped ? type : element) as Record<'type', FC> return after(target, 'type', el => el instanceof Promise ? el.then(hook) : hook(el), diff --git a/plugins/staff-tags/js/patches/details.tsx b/plugins/staff-tags/js/patches/details.tsx index 4595e39..2ef94db 100644 --- a/plugins/staff-tags/js/patches/details.tsx +++ b/plugins/staff-tags/js/patches/details.tsx @@ -1,9 +1,8 @@ import { Stores } from '@revenge-mod/discord/flux' import { getModules } from '@revenge-mod/modules/finders' import { withProps } from '@revenge-mod/modules/finders/filters' -import { afterJSX } from '@revenge-mod/react/jsx-runtime' +import { after } from '@revenge-mod/patcher' import { findInReactFiber } from '@revenge-mod/utils/react' -import { afterRendered } from '../lib/afterRendered' import { fiberFilter } from '../lib/fiber' import { withMemoDefaultName } from '../lib/filters' import getTag from '../lib/getTag' @@ -37,8 +36,23 @@ 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: }` - `mod.default` is the exact reference - * Discord's own `createElement(UserRow, ...)` calls use, which is what `afterJSX` needs. + * callback below receives `{default: }`. + * + * 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. * * Uses `getModules` (reacts once Discord itself initializes each module) instead of * `lookupModule` (which force-initializes uninitialized modules right away) - forcing UserRow / @@ -48,7 +62,7 @@ interface TagComponentProps { * 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. + * 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. */ @@ -73,82 +87,81 @@ export default function patchDetails(storage: JsonStorage) { withMemoDefaultName('UserRow'), mod => { guard(() => { - const UserRow = (mod as { default: FC }).default + const wrapper = (mod as { default: { type: FC } }).default + if (typeof wrapper.type !== 'function') return + patches.push( - afterJSX(UserRow, el => + after(wrapper, 'type', (args, ret) => guard(() => { - const { guildId, user } = el.props - const unpatch = afterRendered(el, ret => - guard(() => { - unpatch() + const props = args?.[0] as UserRowProps | undefined + if (!props?.user) return ret - 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', - ), + 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), + ), ) - return el - }, el), + 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), ), ) }, undefined) diff --git a/plugins/staff-tags/manifest.json b/plugins/staff-tags/manifest.json index b1cddea..7991980 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.7", + "version": "1.0.8", "dependencies": { "revenge.api": { "version": ">=1 <2"