staff-tags: fix afterRendered crash on memo-wrapped components, bump to 1.0.7

Confirmed live: opening the member list crashed with "TypeError: target is
not callable" inside React's renderWithHooks. afterRendered patched
element.type directly, which works for plain function components but not
for UserRow (React.memo()-wrapped): patching the non-callable memo wrapper
object replaces it with a proxy whose *own* stub is callable, which makes
`typeof element.type === 'function'` true even though the real value is a
memo object - React's reconciler checks that before checking $$typeof, so it
takes the plain-function-component path and calls the proxy directly, whose
apply trap forwards to Reflect.apply(realMemoWrapper, ...) and throws, since
the real wrapper was never callable.

Fix: detect a memo-wrapped type and patch its inner .type (the actual render
function memo hides) instead of the wrapper itself - genuinely callable, and
leaves the wrapper's own shape untouched so React's type detection stays
correct. Shared in lib/afterRendered.ts, so it fixes UserRow now and any
future memo-wrapped target automatically.
This commit is contained in:
Ally Founder 2026-08-08 22:51:28 -04:00
parent 980f1b33eb
commit d5e2c25e15
2 changed files with 28 additions and 3 deletions

View file

@ -8,11 +8,36 @@ import type { FC, ReactElement, ReactNode } from 'react'
* reference on an object we just created ourselves - nothing else has captured it yet, so there's
* no same-chunk-destructuring staleness to worry about. See @revenge-mod/patcher's `after` docs
* and the pattern used by Revenge's own `user-badges` plugin (`afterRendered` there).
*
* For a `React.memo()`-wrapped type, `element.type` is a non-callable `{$$typeof, type, compare}`
* object, not a function. Patching `element.type` directly (confirmed live - crashed the member
* list on `UserRow`) replaces it with a proxy whose own stub target *is* callable, which fools
* React's reconciler into taking the plain-function-component fast path (it checks
* `typeof Component === 'function'` before checking `$$typeof`) instead of the memo path - React
* then calls the proxy directly, whose `apply` trap forwards to `Reflect.apply(realMemoWrapper,
* ...)` and throws `TypeError: target is not callable`, since the real memo wrapper was never
* callable. Patching the wrapper's *inner* `.type` instead - the actual render function memo
* hides - avoids this: it's genuinely callable, it's what React actually invokes for a memo
* 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.
*/
export const afterRendered = (
element: ReactElement<any, FC<any>>,
hook: (el: ReactNode) => ReactNode,
) =>
after(element, 'type', el =>
) => {
const type = element.type as unknown as {
type?: unknown
$$typeof?: unknown
}
const isMemoWrapped =
typeof element.type !== 'function' && typeof type?.type === 'function'
const target = (isMemoWrapped ? type : element) as Record<
'type',
FC<any>
>
return after(target, 'type', el =>
el instanceof Promise ? el.then(hook) : hook(el),
)
}

View file

@ -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.6",
"version": "1.0.7",
"dependencies": {
"revenge.api": {
"version": ">=1 <2"