perf: cache per-tweet image parsing and t.co link resolution on x.com - #12432
Merged
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012hAmnQwwcfT7DaWAAugciS
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012hAmnQwwcfT7DaWAAugciS
3 tasks
guanbinrui
added a commit
that referenced
this pull request
Sep 5, 2026
Follow-up to #12432, which fixed the same class of leak for injectAvatar and the tips buttons on x.com. The remaining injection points that create a React tree inside an onNodeMutation/onTargetChanged handler had the same problem: a mutation of the watched element (lazy image src swaps, hover states, feed virtualization reusing nodes) re-runs the handler, which attaches a brand-new shadow-root sibling + React tree without tearing down the previous one. On a long scroll session this accumulates orphaned DOM nodes / shadow roots / React roots (each with its own data-fetching hooks) that are never cleaned up until navigation. - facebook/minds/instagram injectAvatar: call remove() before re-creating the DOMProxy, mirroring the twitter.com fix from #12432. - injectMaskIconToPostTwitter: post.author refines several times as post parsing completes and re-fires on every onNodeMutation of the tweet, so each add() stacked another DOMProxy shadow sibling + <Icon> React root (visible as duplicated Mask icons on the post). Reuse a single proxy, tear down the previous tree first, and release the post.author subscription on signal abort instead of leaking it. - injectMaskUserBadgeAtTwitter (MaskIcon `_` helper): check() re-ran on every mutation and re-called attachReactTreeWithContainer with the same shadow key, which the mounter rejects with a console.error on every floating-bio-card hover while also clobbering `remover` to a no-op. Attach once; remove()/onRemove reset the guard so a genuinely re-added element still re-attaches. - site-adaptor-infra startPostListener: the per-post AbortController wired a listener onto the page-lifetime signal on every post `set` and never removed it, so every post scrolled past left a permanent listener (and its retained controller). Pass { signal: abort.signal } so it drops when the post unmounts. Output values are unchanged; only the redundant/leaked work is removed. Claude-Session: https://claude.ai/code/session_013SLkuieqJXCMg1xoL98jij Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
collectPostInfo/collectLinkson everyonNodeMutationof a tweet node — X's live UI fires this frequently (view counters, hover cards, lazy image src swaps), not just once per post. Each re-run previously spun up a brand-newuntilElementAvailablepolling watcher + re-parsed images from scratch, and re-requestedresolveTCOLink(a cross-context message) for every link in the tweet, even when nothing relevant had changed. Added two small caches — image-parsing promise keyed bytweetNode(WeakMap, auto-GC'd), and resolved t.co link keyed byhref(evicted on failure so a transient error can retry, matching the background resolver's own eviction-on-failure behavior) — so repeated mutations reuse prior work instead of redoing it.injectAvatar,injectTipsButtonOnPost, andinjectTipsButtonOnFollowButtoneach created a freshDOMProxy()inside theironNodeMutation/onTargetChangedhandler for the same watched element. A newDOMProxyalways attaches a brand-new shadow-root sibling, so every mutation of an avatar or tips-button element was injecting another live React tree next to the previous one without ever destroying it — the oldremoverwas simply discarded. Over a long scroll session this accumulated orphaned DOM nodes/shadow roots/React components (each with their own data-fetching hooks) that were never cleaned up until the whole post/cell was removed. Fixed by calling the existingremove()before creating the new tree, matching the pattern already used correctly elsewhere (e.g.MaskIcon's helper reuses the watcher-providedDOMProxyinstead of creating a new one).Test plan
pnpm build/pnpm lint)🤖 Generated with Claude Code
https://claude.ai/code/session_012hAmnQwwcfT7DaWAAugciS