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"