From d5e2c25e1559d2db6f6108a3967e625799dbbc46 Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Sat, 8 Aug 2026 22:51:28 -0400 Subject: [PATCH] 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. --- plugins/staff-tags/js/lib/afterRendered.ts | 29 ++++++++++++++++++++-- plugins/staff-tags/manifest.json | 2 +- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/plugins/staff-tags/js/lib/afterRendered.ts b/plugins/staff-tags/js/lib/afterRendered.ts index c4b1039..6ec050c 100644 --- a/plugins/staff-tags/js/lib/afterRendered.ts +++ b/plugins/staff-tags/js/lib/afterRendered.ts @@ -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>, 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 + > + + return after(target, 'type', el => el instanceof Promise ? el.then(hook) : hook(el), ) +} diff --git a/plugins/staff-tags/manifest.json b/plugins/staff-tags/manifest.json index 6ffc55a..b1cddea 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.6", + "version": "1.0.7", "dependencies": { "revenge.api": { "version": ">=1 <2"