diff --git a/plugins/custom-user-tags/src/patches/profile.tsx b/plugins/custom-user-tags/src/patches/profile.tsx index 66fdfda..caa821e 100644 --- a/plugins/custom-user-tags/src/patches/profile.tsx +++ b/plugins/custom-user-tags/src/patches/profile.tsx @@ -1,74 +1,58 @@ -import { find, findByProps } from "@vendetta/metro"; -import { after } from "@vendetta/patcher"; import { findInReactTree } from "@vendetta/utils"; +import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept"; import resolveTag from "../lib/resolveTag"; - -// Same top-level profile component PronounDB already confirmed works, and the same one Staff Tags -// now patches for its own profile-screen tag - see that plugin's patches/profile.tsx for the full -// writeup on why this is a separate component from UserRow (which details.tsx already handles). -const UserProfile = - find((m: any) => m?.type?.name === "UserProfileContent") ?? - find((m: any) => m?.type?.name === "UserProfile"); - -const TagModule = findByProps("getBotLabel"); +import CustomTag from "../ui/Tag"; /** - * Not independently confirmed against a live render yet - the exact prop shape passed to this - * component isn't verified on-device, so this pulls from whichever of several plausible field names - * is actually present. Every step is guarded, so a wrong assumption just means no tag appears here. + * Full profile screen tag - see Staff Tags' own `patches/profile.tsx` (same repo pattern, worked + * out together in the same live session, see /root/evals-for-rn) for the full writeup of why this + * needs `createElement`/`jsx` interception rather than patching `UserProfileContent` and searching + * its output: `UserProfilePrimaryInfo` (the component that actually renders the name+tag row) is + * only ever created as an implementation detail several layers deep, unreachable by a Metro module + * search, and receives `user` directly as its own prop - so `registerTypeDetector` catches the + * first render to recover the live reference, and `registerIntercept` swaps in a wrapper for every + * later use, no separate context-passing patch needed. + * + * Unlike Staff Tags, `resolveTag` only needs a `userId` - no guild/permission context - so this + * doesn't need `guildId` from props at all. */ export default function patchProfile(): () => void { - if (!UserProfile) return () => {}; + const cleanups: (() => void)[] = []; + patchCreateElement(cleanups); - return after("type", UserProfile, (args: any[], res: any) => { - try { - const props = args?.[0] ?? {}; - const userId: string | undefined = props.userId ?? props.user?.id ?? props.displayProfile?.userId; - if (!userId) return; + registerTypeDetector( + "custom-user-tags-profile-primary-info", + (type) => typeof type === "function" && type.name === "UserProfilePrimaryInfo", + (UserProfilePrimaryInfo: any) => { + const PatchedUserProfilePrimaryInfo = (props: any) => { + const ret = UserProfilePrimaryInfo(props); - const tag = resolveTag(userId); - if (!tag) return; + try { + const userId: string | undefined = props?.user?.id; + if (!userId) return ret; - const primaryInfo = findInReactTree(res, (c) => c?.type?.name === "PrimaryInfo" || c?.type?.displayName === "PrimaryInfo"); - if (!primaryInfo) return; + const tag = resolveTag(userId); + if (!tag) return ret; - const existingTag = findInReactTree(primaryInfo, (c) => c?.type?.Types); - if (existingTag && existingTag.props?.type !== 0) return; + const row = findInReactTree( + ret, + (c: any) => + Array.isArray(c?.props?.children) && + c.props.children.some((ch: any) => ch?.type?.name === "UserTagAndPronouns") + ); + if (!Array.isArray(row?.props?.children)) return ret; - if (existingTag) { - Object.assign(existingTag.props, { - type: 0, - text: tag.text, - textColor: tag.textColor, - backgroundColor: tag.backgroundColor, - icon: tag.icon, - iconColor: tag.iconColor, - verified: false - }); - return; - } + row.props.children.push(); + } catch { + // Skip - a broken profile tag beats a crashed profile screen. + } - const nameRow = findInReactTree( - primaryInfo, - (c) => - Array.isArray(c?.props?.children) && - c.props.children.some((ch: any) => typeof ch === "string" || typeof ch?.props?.children === "string") - ); - if (!Array.isArray(nameRow?.props?.children) || !TagModule) return; + return ret; + }; - nameRow.props.children.push( - - ); - } catch { - // Skip - a broken profile tag beats a crashed profile screen. + registerIntercept(UserProfilePrimaryInfo, PatchedUserProfilePrimaryInfo); } - }); + ); + + return () => cleanups.forEach((fn) => fn()); } diff --git a/plugins/staff-tags/src/patches/profile.tsx b/plugins/staff-tags/src/patches/profile.tsx index ed1915c..0d78362 100644 --- a/plugins/staff-tags/src/patches/profile.tsx +++ b/plugins/staff-tags/src/patches/profile.tsx @@ -1,86 +1,91 @@ -import { find, findByProps, findByStoreName } from "@vendetta/metro"; -import { after } from "@vendetta/patcher"; +import { findByStoreName } from "@vendetta/metro"; import { findInReactTree } from "@vendetta/utils"; +import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept"; import getTag from "../lib/getTag"; +import GradientTag from "../ui/GradientTag"; -// Same top-level profile component PronounDB already confirmed works on current Discord (the -// findByName-based lookup this repo used to use for it stopped matching after a rename, but this -// predicate-based find still succeeds). The full profile screen doesn't render through UserRow the -// way member list rows and the old "profile popout" did - this is a separate component entirely, -// which is why tags weren't showing up here even though details.tsx's own patch is working fine. -const UserProfile = - find((m: any) => m?.type?.name === "UserProfileContent") ?? - find((m: any) => m?.type?.name === "UserProfile"); - -const TagModule = findByProps("getBotLabel"); const GuildStore = findByStoreName("GuildStore"); -const UserStore = findByStoreName("UserStore"); /** - * Not independently confirmed against a live render the way most of this repo's fixes are - the - * exact prop shape passed to this component's render (userId vs. a full user object, whether - * guildId is even present) isn't verified on-device yet, so this pulls from whichever of several - * plausible field names is actually present rather than assuming one. Every step is guarded, so a - * wrong assumption just means no tag appears here, not a crash. + * Full profile screen tag, live-verified end to end (see /root/evals-for-rn for the session this + * was worked out in, and the Next repo's `staff-tags` `patches/details.tsx` for the exact same + * class of bug this replaces one of). + * + * The full profile doesn't render through `UserRow` (member list/profile popout) at all - it's a + * separate tree, `UserProfileContent` -> `PrimaryInfo` -> `UserProfilePrimaryInfo`, and neither + * `PrimaryInfo` nor `UserProfileContent` carry a rendered name+tag row worth patching: + * `PrimaryInfo` only ever appears as an *unrendered* `` + * element inside `UserProfileContent`'s own output (confirmed live it has no `children` at that + * point, so searching inside it for anything finds nothing, silently, no matter how good the + * search predicate is). `UserProfilePrimaryInfo` is the component that actually renders the name + * row and, conveniently, receives `user`/`guildId` directly as its own props - no need to also + * patch `UserProfileContent` just to recover context. + * + * `UserProfilePrimaryInfo` can't be found by a Metro module search the way `UserProfileContent` + * is above (confirmed live - it isn't a top-level export findByProps/findByTypeName can reach, + * only ever created as an implementation detail inside `PrimaryInfo`'s own render). Patching + * `React.createElement`/the `jsx` runtime (`@shared/lib/createElementIntercept`) sidesteps that: + * `registerTypeDetector` observes the *first* time anything, anywhere, creates an element with + * this name, handing back the live function reference the moment it exists - then + * `registerIntercept` swaps in our own wrapper for every future use of that exact reference, + * including the one that triggered the detector (both run synchronously inside the same + * `createElement`/`jsx` call, so even the very first render is already patched). + * + * Confirmed live, `UserProfilePrimaryInfo` renders: + * `[, [, , ]]` + * - `UserTagAndPronouns` is Discord's `@username` handle + pronouns display, not a badge slot + * (misleading name); `GuildTag` is Discord's own unrelated "server tag" feature; `ProfileBadgeRows` + * is Nitro/HypeSquad-style badges. None of these read custom text/color, so - same lesson as the + * member list's `BotTag` - the fix pushes the plugin's own `GradientTag` as a new sibling in that + * inner row rather than trying to repurpose any of them. */ export default function patchProfile(): () => void { - if (!UserProfile) return () => {}; + const cleanups: (() => void)[] = []; + patchCreateElement(cleanups); - return after("type", UserProfile, (args: any[], res: any) => { - try { - const props = args?.[0] ?? {}; - const userId: string | undefined = props.userId ?? props.user?.id ?? props.displayProfile?.userId; - if (!userId) return; + registerTypeDetector( + "staff-tags-profile-primary-info", + (type) => typeof type === "function" && type.name === "UserProfilePrimaryInfo", + (UserProfilePrimaryInfo: any) => { + const PatchedUserProfilePrimaryInfo = (props: any) => { + const ret = UserProfilePrimaryInfo(props); - const user = props.user ?? UserStore?.getUser?.(userId); - if (!user) return; + try { + const { user, guildId } = props ?? {}; + if (!user) return ret; - const guildId: string | undefined = props.guildId; - const guild = guildId ? GuildStore?.getGuild(guildId) : undefined; + const guild = guildId ? GuildStore?.getGuild(guildId) : undefined; + const tag = getTag(guild, undefined, user); + if (!tag) return ret; - const tag = getTag(guild, undefined, user); - if (!tag) return; + const row = findInReactTree( + ret, + (c: any) => + Array.isArray(c?.props?.children) && + c.props.children.some((ch: any) => ch?.type?.name === "UserTagAndPronouns") + ); + if (!Array.isArray(row?.props?.children)) return ret; - const primaryInfo = findInReactTree(res, (c) => c?.type?.name === "PrimaryInfo" || c?.type?.displayName === "PrimaryInfo"); - if (!primaryInfo) return; + row.props.children.push( + + ); + } catch { + // Skip - a broken profile tag beats a crashed profile screen. + } - const existingTag = findInReactTree(primaryInfo, (c) => c?.type?.Types); - if (existingTag && existingTag.props?.type !== 0) return; + return ret; + }; - if (existingTag) { - Object.assign(existingTag.props, { - type: 0, - text: tag.text, - textColor: tag.textColor, - backgroundColor: tag.backgroundColor, - icon: tag.icon, - iconColor: tag.iconColor, - verified: false - }); - return; - } - - const nameRow = findInReactTree( - primaryInfo, - (c) => - Array.isArray(c?.props?.children) && - c.props.children.some((ch: any) => typeof ch === "string" || typeof ch?.props?.children === "string") - ); - if (!Array.isArray(nameRow?.props?.children) || !TagModule) return; - - nameRow.props.children.push( - - ); - } catch { - // Skip - a broken profile tag beats a crashed profile screen. + registerIntercept(UserProfilePrimaryInfo, PatchedUserProfilePrimaryInfo); } - }); + ); + + return () => cleanups.forEach((fn) => fn()); } diff --git a/shared/lib/createElementIntercept.ts b/shared/lib/createElementIntercept.ts index 9f375b6..5f21f9e 100644 --- a/shared/lib/createElementIntercept.ts +++ b/shared/lib/createElementIntercept.ts @@ -1,6 +1,4 @@ import { React } from "@vendetta/metro/common"; -import { rawFind } from "@shared/lib/rawFind"; -import { waitFor } from "@shared/lib/waitFor"; interface Intercept { replacement: React.ComponentType; @@ -12,14 +10,24 @@ interface PropsIntercept { replacement: React.ComponentType | null; } +interface PropsTransform { + predicate: (props: any) => boolean; + transform: (props: any) => any; +} + interface TypeDetector { + key: string; predicate: (type: any) => boolean; onDetected: (type: any) => void; + persistent: boolean; + justFired?: boolean; } const intercepts = new Map, Intercept>(); const propsIntercepts: PropsIntercept[] = []; +const propsTransforms: PropsTransform[] = []; let typeDetectors: TypeDetector[] = []; +const detectorKeys = new Set(); let isPatched = false; export function registerIntercept( @@ -42,26 +50,67 @@ export function registerPropsIntercept(predicate: (props: any) => boolean, repla } /** - * Purely observational, one-shot: the first time an element is created whose `type` matches - * `predicate`, calls `onDetected(type)` with the real, live type reference and then stops - * watching - it never alters what actually renders. + * Rewrites an element's props in place while keeping the same type - unlike registerPropsIntercept, + * this doesn't swap in a different component, it lets the real one render with different input. + * + * Exists because monkey-patching a hook's exported property (`someModule.useSomeHook = wrapper`) + * only affects callers that go through that exact exports-object reference. Discord's own hooks are + * often called via an internal, same-chunk closure reference instead - the patched export sits there + * unused, and the real, unpatched hook result flows through untouched. A React.createElement/jsx call + * has no such ambiguity: it's always the one shared runtime function, called the same way regardless + * of which chunk the JSX originated in - so rewriting an element's props here reaches every consumer + * exactly as if the underlying data had actually been different. + */ +export function registerPropsTransform(predicate: (props: any) => boolean, transform: (props: any) => any) { + propsTransforms.push({ predicate, transform }); +} + +/** + * Purely observational: the first time an element is created whose `type` matches `predicate`, + * calls `onDetected(type)` with the real, live type reference - it never alters what actually + * renders (pair with registerIntercept/registerPropsIntercept in the callback for that). * * This exists for components that can't be found reliably by searching Metro's module registry * after the fact (findByTypeName/rawFindByTypeName), because that races against whenever the * component first mounts - if it mounts before the search finds it, and it never mounts again for - * the rest of the session (true for some React.memo'd chrome components), the search losing that - * race means the patch never gets a chance to apply at all, permanently. Intercepting element - * creation instead sidesteps the race entirely: by the time ANYTHING calls createElement/jsx with - * this component as `type`, that reference already exists and is already the real one about to be - * used - there's no way to observe the call any earlier than this. + * the rest of the session (true for some React.memo'd chrome components, and for inner components + * only reachable by rendering an outer one first), the search losing that race means the patch never + * gets a chance to apply at all, permanently. Intercepting element creation instead sidesteps the + * race entirely: by the time ANYTHING calls createElement/jsx with this component as `type`, that + * reference already exists and is already the real one about to be used - there's no way to observe + * the call any earlier than this. + * + * `key` deduplicates registrations - important if the call site that registers a detector can run + * more than once (a retry loop, a patch that reapplies on reload), since duplicate detectors mean + * duplicate `onDetected` firings and a permanently growing list re-tested against every element + * created anywhere in the app. + * + * `persistent` (default false, one-shot): keeps watching and firing `onDetected` again for every + * later match too, instead of stopping after the first. Needed when the component's own type + * reference isn't a stable session-long singleton (confirmed live for some chrome components, whose + * reference changes on certain navigation paths) - a one-shot detector plus registerIntercept's + * identity-keyed map can't follow that: the intercept stays registered for the old, now-abandoned + * reference while the new one renders unmodified. */ -export function registerTypeDetector(predicate: (type: any) => boolean, onDetected: (type: any) => void) { - typeDetectors.push({ predicate, onDetected }); +export function registerTypeDetector( + key: string, + predicate: (type: any) => boolean, + onDetected: (type: any) => void, + options?: { persistent?: boolean }, +) { + if (detectorKeys.has(key)) return; + detectorKeys.add(key); + typeDetectors.push({ key, predicate, onDetected, persistent: options?.persistent ?? false }); +} + +export function hasTypeDetector(key: string): boolean { + return detectorKeys.has(key); } function runTypeDetectors(type: any) { if (typeDetectors.length === 0) return; - const remaining: TypeDetector[] = []; + + let consumed = false; for (const detector of typeDetectors) { let matched = false; try { @@ -69,28 +118,49 @@ function runTypeDetectors(type: any) { } catch { // A bad predicate shouldn't block every other detector or every element from rendering. } - if (matched) { - try { - detector.onDetected(type); - } catch { - // Same - a detector's own handler throwing shouldn't take anything else down. - } - } else { - remaining.push(detector); + if (!matched) continue; + + try { + detector.onDetected(type); + } catch { + // Same - a detector's own handler throwing shouldn't take anything else down. + } + if (!detector.persistent) { + detector.justFired = true; + consumed = true; } } - typeDetectors = remaining; + + // Only rebuild the list when a one-shot detector actually fired - rebuilding on every single + // element creation app-wide would be an allocation per element on the hottest path in the app + // whenever any persistent detector is registered (the list is then never empty). + if (consumed) { + typeDetectors = typeDetectors.filter((d) => d.persistent || !d.justFired); + } } /** Returns a replacement type if this element should be intercepted, `null` to render nothing, or `undefined` to pass through unchanged. */ -function resolveReplacement(type: any, props: any): { type: any; props: any } | null | undefined { +function resolveReplacement(type: any, props: any, rest: any[]): { type: any; props: any } | null | undefined { runTypeDetectors(type); - if (props) { + let effectiveProps = props; + if (effectiveProps) { + for (const { predicate, transform } of propsTransforms) { + try { + if (predicate(effectiveProps, type, rest)) { + effectiveProps = transform(effectiveProps); + } + } catch { + // A bad transform shouldn't block every other element from rendering. + } + } + } + + if (effectiveProps) { for (const { predicate, replacement } of propsIntercepts) { try { - if (predicate(props)) { - return replacement ? { type: replacement, props } : null; + if (predicate(effectiveProps, type, rest)) { + return replacement ? { type: replacement, props: effectiveProps } : null; } } catch { // A bad predicate shouldn't block every other element from rendering. @@ -100,12 +170,25 @@ function resolveReplacement(type: any, props: any): { type: any; props: any } | const entry = intercepts.get(type); if (entry) { - return { type: entry.replacement, props: entry.extraProps ? { ...props, ...entry.extraProps } : props }; + return { type: entry.replacement, props: entry.extraProps ? { ...effectiveProps, ...entry.extraProps } : effectiveProps }; + } + + if (effectiveProps !== props) { + return { type, props: effectiveProps }; } return undefined; } +function makeWrapper(orig: Function, thisArg: any) { + return function (this: any, type: any, props: any, ...rest: any[]) { + const resolved = resolveReplacement(type, props, rest); + if (resolved === null) return null; + if (resolved) return orig.call(thisArg ?? this, resolved.type, resolved.props ?? null, ...rest); + return orig.call(thisArg ?? this, type, props ?? null, ...rest); + }; +} + export function patchCreateElement(cleanups: (() => void)[]) { if (isPatched) return; @@ -115,60 +198,124 @@ export function patchCreateElement(cleanups: (() => void)[]) { return; } - const patchedCreateElement = function (type: any, props: any, ...rest: any[]) { - const resolved = resolveReplacement(type, props); - if (resolved === null) return null; - if (resolved) return origCreateElement.call(React, resolved.type, resolved.props, ...rest); - return origCreateElement.call(React, type, props, ...rest); - }; - + const patchedCreateElement = makeWrapper(origCreateElement, React); Object.assign(patchedCreateElement, origCreateElement); React.createElement = patchedCreateElement as typeof React.createElement; isPatched = true; // Modern React/RN builds (Discord's included) compile JSX through the automatic runtime - // (jsx/jsxs from react/jsx-runtime) instead of React.createElement - patching only - // createElement misses every one of Discord's own render calls entirely. Found by shape - // (jsx/jsxs/Fragment together), not by name, for the same reason everything else in this file - // avoids name-based lookups. + // (jsx/jsxs/jsxDEV from react/jsx-runtime) instead of React.createElement - patching only + // createElement misses effectively every one of Discord's own render calls. Found by shape + // (jsx/jsxs/jsxDEV together), not by name, since runtime function names get mangled/stripped. // - // This used to be a single one-shot rawFind() call - if the jsx-runtime module hadn't been - // required yet at the exact moment this ran (very early in boot, when a plugin's onLoad first - // calls this), jsx/jsxs would never get patched for the rest of the session at all, silently - // (only a console.warn, invisible in normal use). Since Discord renders almost everything - // through jsx/jsxs and not createElement, that made every registerTypeDetector/ - // registerPropsIntercept consumer's patch a near-total no-op whenever that race was lost - - // plausibly the real reason a component-creation-interception approach could still miss a - // component that only ever renders once, early in boot. Retrying via waitFor (with the same - // passive rawFind, so it still never force-requires anything) closes that gap. + // ALL matching runtime modules get patched, not just the first one found, and the scan keeps + // running instead of stopping at the first success: a bundle can hold more than one copy of the + // runtime (different chunks importing different copies), and Metro registers modules lazily, so + // a copy belonging to a chunk that hasn't been required yet at boot simply does not exist to be + // found at that moment - a one-shot search permanently misses every copy registered later. const restoreJsx: (() => void)[] = []; - const jsxHandle = waitFor( - () => rawFind((m: any) => typeof m?.jsx === "function" && typeof m?.jsxs === "function" && "Fragment" in m), - (jsxRuntime: any) => { - for (const key of ["jsx", "jsxs"] as const) { - const orig = jsxRuntime[key]; - if (typeof orig !== "function") continue; + const patchedJsxRuntimes = new WeakSet(); - jsxRuntime[key] = function (type: any, props: any, ...rest: any[]) { - const resolved = resolveReplacement(type, props); - if (resolved === null) return null; - if (resolved) return orig.call(this, resolved.type, resolved.props, ...rest); - return orig.call(this, type, props, ...rest); - }; - restoreJsx.push(() => { jsxRuntime[key] = orig; }); + function isJsxRuntime(m: any): boolean { + return typeof m?.jsx === "function" || typeof m?.jsxs === "function" || typeof m?.jsxDEV === "function"; + } + + function patchJsxObject(runtime: any) { + if (patchedJsxRuntimes.has(runtime)) return; + patchedJsxRuntimes.add(runtime); + for (const key of ["jsx", "jsxs", "jsxDEV"] as const) { + const orig = runtime[key]; + if (typeof orig !== "function") continue; + runtime[key] = makeWrapper(orig, runtime); + restoreJsx.push(() => { runtime[key] = orig; }); + } + } + + function wrapJsxFunction(runtime: any): any { + const wrappedJsx = makeWrapper(runtime, runtime); + for (const key of ["jsxs", "jsxDEV"] as const) { + const orig = runtime[key]; + if (typeof orig === "function") { + wrappedJsx[key] = makeWrapper(orig, runtime); } } - ); + if ("Fragment" in runtime) { + wrappedJsx.Fragment = runtime.Fragment; + } + return wrappedJsx; + } + + function patchJsxModule(def: any) { + if (!def?.publicModule?.exports) return; + const exports = def.publicModule.exports; + + try { + if (typeof exports === "function" && isJsxRuntime(exports) && !patchedJsxRuntimes.has(exports)) { + patchedJsxRuntimes.add(exports); + const wrapped = wrapJsxFunction(exports); + def.publicModule.exports = wrapped; + restoreJsx.push(() => { def.publicModule.exports = exports; }); + } else if (isJsxRuntime(exports) && !patchedJsxRuntimes.has(exports)) { + patchedJsxRuntimes.add(exports); + patchJsxObject(exports); + } + + const dflt = exports.default; + if (dflt != null && typeof dflt === "function" && isJsxRuntime(dflt) && !patchedJsxRuntimes.has(dflt)) { + patchedJsxRuntimes.add(dflt); + const wrapped = wrapJsxFunction(dflt); + exports.default = wrapped; + restoreJsx.push(() => { exports.default = dflt; }); + } + } catch { + // Ignore one module's bad shape and continue scanning. + } + } + + function scanAndPatchJsxRuntimes() { + const modules = (window as any)?.modules; + if (!modules) return; + for (const id in modules) { + const def = modules[id]; + if (!def?.isInitialized) continue; + patchJsxModule(def); + } + } + + // Patch any jsx runtimes already loaded before Discord's first render pass starts, synchronously + // - an async scan would complete too late for the very first render of an early-mounting target. + scanAndPatchJsxRuntimes(); + + // Fast while the app is still booting and chunks are being pulled in, then slow, then stop - a + // full window.modules walk is not free and there's nothing left to catch once the UI has settled. + let ticks = 0; + let timer: ReturnType | undefined = setInterval(() => { + scanAndPatchJsxRuntimes(); + if (++ticks === 50 && timer) { // ~5s at 100ms + clearInterval(timer); + timer = setInterval(() => { + scanAndPatchJsxRuntimes(); + if (++ticks >= 75 && timer) { // + ~25s at 1s + clearInterval(timer); + timer = undefined; + } + }, 1000); + } + }, 100); cleanups.push(() => { - jsxHandle.cancel(); + if (timer) clearInterval(timer); + timer = undefined; if (isPatched) { React.createElement = origCreateElement; isPatched = false; } restoreJsx.forEach((fn) => fn()); + restoreJsx.length = 0; intercepts.clear(); propsIntercepts.length = 0; + propsTransforms.length = 0; typeDetectors = []; + detectorKeys.clear(); }); }