From 91b1dceb9a9285fb372da276f766f7c6927c0909 Mon Sep 17 00:00:00 2001 From: guanbinrui Date: Sat, 5 Sep 2026 13:19:04 +0800 Subject: [PATCH 1/2] perf: cache per-tweet image parsing and t.co link resolution on x.com The timeline post collector re-runs collectPostInfo/collectLinks on every onNodeMutation of a tweet node (view counters, hover cards, lazy image loads, etc. all fire this on x.com). Each re-run used to spin up a brand new untilElementAvailable polling watcher for image steganography parsing and re-request resolveTCOLink for every link, even when nothing relevant had changed. This caches both by tweetNode/href respectively so repeated mutations reuse prior work instead of redoing it, reducing scripting cost while scrolling a busy timeline. Output values are unchanged; only the redundant recomputation is removed. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012hAmnQwwcfT7DaWAAugciS --- .../twitter.com/collecting/post.ts | 45 +++++++++++++++++-- 1 file changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/mask/content-script/site-adaptors/twitter.com/collecting/post.ts b/packages/mask/content-script/site-adaptors/twitter.com/collecting/post.ts index 08091dc6ff6..5c3817ebcc2 100644 --- a/packages/mask/content-script/site-adaptors/twitter.com/collecting/post.ts +++ b/packages/mask/content-script/site-adaptors/twitter.com/collecting/post.ts @@ -175,6 +175,24 @@ export function collectVerificationPost(keyword: string) { return null } +// A tweet's DOM node fires onNodeMutation repeatedly (view counters, hover cards, lazy-loaded +// media swapping placeholder -> real src, etc.) which re-invokes collectPostInfo for the same +// tweetNode many times. Without caching, each invocation spun up a brand-new polling watcher +// (untilElementAvailable) and re-parsed the images from scratch, stacking up redundant work per +// tweet while scrolling. The image set for a given tweetNode is stable, so it's safe to compute +// it once and reuse the promise on subsequent mutations. +const postImagesCache = new WeakMap>() +function getPostImages(tweetNode: HTMLElement) { + let cached = postImagesCache.get(tweetNode) + if (!cached) { + cached = untilElementAvailable(postsImageSelector(tweetNode), 10_000) + .then(() => postImagesParser(tweetNode)) + .catch(() => []) + postImagesCache.set(tweetNode, cached) + } + return cached +} + function collectPostInfo( tweetNode: HTMLDivElement | null, info: ReturnType, @@ -193,8 +211,7 @@ function collectPostInfo( // decode stenographic image // don't add await on this - const images = untilElementAvailable(postsImageSelector(tweetNode), 10_000) - .then(() => postImagesParser(tweetNode)) + const images = getPostImages(tweetNode) .then((images) => { for (const image of images) { if (typeof image.image === 'string') info.postMetadataImages.add(image.image) @@ -210,6 +227,28 @@ function collectPostInfo( ) } +// Same tweetNode mutating repeatedly also re-ran collectLinks over and over, which re-requested +// resolveTCOLink for the same href every time. The background resolver already memoizes by URL, +// but each call still pays for a full cross-context message round trip. t.co redirects are +// static, so caching the in-flight/resolved promise by href here is safe and cuts that traffic +// (it also dedupes the same link appearing across different tweets, e.g. popular/retweeted links). +const tcoLinkCache = new Map>() +function resolveTCOLinkCached(href: string) { + let cached = tcoLinkCache.get(href) + if (!cached) { + cached = Services.Helper.resolveTCOLink(href).catch((error: unknown) => { + // Don't let a transient failure permanently poison this href for the rest of the + // session (mirrors the eviction-on-failure behavior of the background's own + // memoizePromise-wrapped resolver) — allow the next occurrence to retry. A + // resolved `null` (e.g. not a t.co link) is a legitimate answer and stays cached. + tcoLinkCache.delete(href) + throw error + }) + tcoLinkCache.set(href, cached) + } + return cached +} + function collectLinks( tweetNode: HTMLDivElement | null, info: ReturnType, @@ -229,7 +268,7 @@ function collectLinks( if (seen.has(x.href)) continue seen.add(x.href) info.postMetadataMentionedLinks.set(x, x.href) - Services.Helper.resolveTCOLink(x.href) + resolveTCOLinkCached(x.href) .then((val) => { if (cancel?.aborted) return if (!val) return From 65356f8345f67bc9b6aa6d42620b957428c3f0fe Mon Sep 17 00:00:00 2001 From: guanbinrui Date: Sat, 5 Sep 2026 13:31:47 +0800 Subject: [PATCH 2/2] fix: stop leaking React trees on avatar/tips button re-renders on x.com injectAvatar, injectTipsButtonOnPost, and injectTipsButtonOnFollowButton each create a fresh DOMProxy() inside their onNodeMutation/onTargetChanged handler for the same watched element. Since a new DOMProxy always attaches a brand-new shadow-root sibling, every mutation of an avatar or tips button element (lazy image loads, hover states, X's virtualization reusing nodes) was injecting another live React tree next to the previous one without ever destroying it - the old `remover` was simply discarded. On a long scroll session this accumulates orphaned DOM nodes/shadow roots/React components (each with their own data-fetching hooks) that are never cleaned up until the whole post/cell is removed. Call the existing `remove()` before creating the new tree so at most one is ever live per element, matching the pattern already used correctly elsewhere (e.g. MaskIcon's helper reuses the watcher-provided DOMProxy instead of creating a new one). No behavior/output change other than removing the leaked duplicates. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012hAmnQwwcfT7DaWAAugciS --- .../site-adaptors/twitter.com/injection/Avatar/index.tsx | 5 +++++ .../twitter.com/injection/Tips/FollowTipsButton.tsx | 4 ++++ .../twitter.com/injection/Tips/PostTipsButton.tsx | 4 ++++ 3 files changed, 13 insertions(+) diff --git a/packages/mask/content-script/site-adaptors/twitter.com/injection/Avatar/index.tsx b/packages/mask/content-script/site-adaptors/twitter.com/injection/Avatar/index.tsx index a6996b8d32f..5e0fabef4dc 100644 --- a/packages/mask/content-script/site-adaptors/twitter.com/injection/Avatar/index.tsx +++ b/packages/mask/content-script/site-adaptors/twitter.com/injection/Avatar/index.tsx @@ -36,6 +36,11 @@ export async function injectAvatar(signal: AbortSignal) { const run = async () => { const twitterId = getTwitterId(ele) if (!twitterId) return + // onNodeMutation/onTargetChanged re-run this for the same `ele` (X mutates avatar + // containers often: lazy image src swaps, hover states, virtualization reuse). A + // fresh DOMProxy() here creates a brand-new shadow-root sibling every time, so the + // previous one must be torn down first or it leaks a live React tree per mutation. + remove() const proxy = DOMProxy({ afterShadowRootInit: Flags.shadowRootInit, diff --git a/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/FollowTipsButton.tsx b/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/FollowTipsButton.tsx index 386e9220b6f..58c6364e9c2 100644 --- a/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/FollowTipsButton.tsx +++ b/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/FollowTipsButton.tsx @@ -28,6 +28,10 @@ export function injectTipsButtonOnFollowButton(signal: AbortSignal) { const run = async () => { const userId = getUserId(ele) if (!userId) return + // onNodeMutation/onTargetChanged re-run this for the same `ele`. A fresh DOMProxy() + // here creates a brand-new shadow-root sibling every time, so the previous one must + // be torn down first or it leaks a live React tree on every mutation of this cell. + remove() const proxy = DOMProxy({ afterShadowRootInit: Flags.shadowRootInit, }) diff --git a/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx b/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx index 0375e78dcff..3d098664610 100644 --- a/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx +++ b/packages/mask/content-script/site-adaptors/twitter.com/injection/Tips/PostTipsButton.tsx @@ -43,6 +43,10 @@ export function injectTipsButtonOnPost(signal: AbortSignal) { const run = async () => { const userId = getUserId(ele) if (!userId) return + // onNodeMutation/onTargetChanged re-run this for the same `ele`. A fresh DOMProxy() + // here creates a brand-new shadow-root sibling every time, so the previous one must + // be torn down first or it leaks a live React tree on every mutation of this post. + remove() const proxy = DOMProxy({ afterShadowRootInit: Flags.shadowRootInit, })