staff-tags: fix member list tags for real, bump to 1.0.9

1.0.8 fixed the row-pairing bug but kept patching the shared UserRow
memo wrapper's .type via @revenge-mod/patcher's after() - confirmed
live that this breaks member list rendering outright, unconditionally,
even as a single non-stacking patch on a fresh app process. Replaced
with afterJSX() intercepting each createElement(UserRow, props) call
and a plain property assignment (el.type = a single stable function),
never touching the shared object via after()'s Proxy machinery.

Also fixed the actual tag-injection logic, live-verified end to end:
UserRow renders a single TableRow with name+tag at ret.props.label (a
prop, not a child reachable by the old generic tree search - that
mismatch was the reason tags never appeared even before the crash).
The existing tag element found there is Discord's own BotTag, which
derives its label purely from a type enum and ignores any custom
text/color props - so the old "mutate BotTag in place" path was a
silent no-op. Now always injects the plugin's own GradientTag
instead, confirmed live to actually render custom tag text and color.
This commit is contained in:
Ally Founder 2026-08-09 01:05:20 -04:00
parent d128252024
commit abd21f3be1
2 changed files with 92 additions and 117 deletions

View file

@ -1,7 +1,6 @@
import { Stores } from '@revenge-mod/discord/flux' import { Stores } from '@revenge-mod/discord/flux'
import { getModules } from '@revenge-mod/modules/finders' 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 { findInReactFiber } from '@revenge-mod/utils/react'
import { fiberFilter } from '../lib/fiber' import { fiberFilter } from '../lib/fiber'
import { withMemoDefaultName } from '../lib/filters' import { withMemoDefaultName } from '../lib/filters'
@ -9,7 +8,7 @@ import getTag from '../lib/getTag'
import { guard } from '../lib/safe' import { guard } from '../lib/safe'
import GradientTag from '../ui/GradientTag' import GradientTag from '../ui/GradientTag'
import type { JsonStorage } from '@revenge-mod/json-storage' 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' import type { StaffTagsStorage } from '../lib/types'
interface UserRowProps { interface UserRowProps {
@ -17,14 +16,6 @@ interface UserRowProps {
user: any 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. * 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 * 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 * 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 * the wrapper's inner `.type.name` instead and matches on the full module namespace, so the
* callback below receives `{default: <memo wrapper>}`. * callback below receives `{default: <memo wrapper>}` - `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 * Does NOT patch the memo wrapper's shared inner `.type` via `@revenge-mod/patcher`'s generic
* pattern `chat.ts` uses on `getTagProperties`), reading `props` fresh from `args[0]` on every * `after()` (tried and confirmed live to be unsound - see git history on this file). That's true
* call - NOT the old per-`createElement` `afterJSX`+`afterRendered`, self-unpatching approach. * even for a single, non-stacking, persistent `after()` call: wrapping the shared render function
* That approach is unsound for memo components specifically: every row instance shares the exact * in `after()`'s Proxy broke member list rendering outright, every time, even on a fully fresh app
* same inner render function (memo only produces one), so patching it per-instance still patches * process with nothing else patched. Whatever `after()` does to make a target "patchable" doesn't
* one shared target, and every currently-stacked self-unpatching hook fires on *any* row's actual * survive contact with this particular shared function.
* 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 * Instead, `afterJSX(wrapper, el => ...)` intercepts each `createElement(UserRow, props)` call
* `lookupModule` (which force-initializes uninitialized modules right away) - forcing UserRow / * (the per-instance element, never the shared object) and does a **plain, unwrapped property
* the Tag module to init during `start()`, before Discord is done booting, is what crashed app * assignment** - `el.type = patchedRender` - to a single stable function reused for every row
* startup the first time this shipped. * (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/ * 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 * 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 * synchronous setup calls - an uncaught throw here would otherwise skip a row's tag at best, or
* one row, and (per the above) would otherwise poison the shared render function for every row. * (per the shared-target lesson above) risk destabilizing every row's rendering at worst.
*
* TODO(live-verify): rendered-tree shape (`c?.type?.Types`) still unconfirmed, see eval-for-revenge.
*/ */
export default function patchDetails(storage: JsonStorage<StaffTagsStorage>) { export default function patchDetails(storage: JsonStorage<StaffTagsStorage>) {
const patches: (() => void)[] = [] const patches: (() => void)[] = []
let TagModule: { default?: FC<TagComponentProps> } | undefined
const unsubTagModule = getModules(
withProps('default', 'getBotLabel'),
mod => {
TagModule = mod as { default?: FC<TagComponentProps> }
},
{ skipDefault: true },
)
patches.push(unsubTagModule)
const GuildStore = Stores.GuildStore as unknown as { const GuildStore = Stores.GuildStore as unknown as {
getGuild(id: string | undefined): any getGuild(id: string | undefined): any
} }
@ -88,80 +89,54 @@ export default function patchDetails(storage: JsonStorage<StaffTagsStorage>) {
mod => { mod => {
guard(() => { guard(() => {
const wrapper = (mod as { default: { type: FC<UserRowProps> } }).default const wrapper = (mod as { default: { type: FC<UserRowProps> } }).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<UserRowProps> = 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(
<GradientTag
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
gradientColor={tag.gradientColor}
/>,
)
return ret
}, ret)
}
patches.push( patches.push(
after(wrapper, 'type', (args, ret) => afterJSX(wrapper as unknown as FC<UserRowProps>, el =>
guard(() => { guard(() => {
const props = args?.[0] as UserRowProps | undefined ;(el as { type: unknown }).type = patchedRender
if (!props?.user) return ret return el
}, el),
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(
<GradientTag
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
gradientColor={tag.gradientColor}
/>,
)
} else if (TagModule?.default) {
const Component = TagModule.default
container.props.children.push(
<Component
type={0}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
verified={tag.verified}
/>,
)
}
return ret
}, ret),
), ),
) )
}, undefined) }, undefined)

View file

@ -4,7 +4,7 @@
"name": "Staff Tags", "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.", "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", "author": "Rosie",
"version": "1.0.8", "version": "1.0.9",
"dependencies": { "dependencies": {
"revenge.api": { "revenge.api": {
"version": ">=1 <2" "version": ">=1 <2"