Skip to content

perf: cache per-tweet image parsing and t.co link resolution on x.com - #12432

Merged
guanbinrui merged 2 commits into
developfrom
worktree-twitter-perf-opt
Sep 5, 2026
Merged

perf: cache per-tweet image parsing and t.co link resolution on x.com#12432
guanbinrui merged 2 commits into
developfrom
worktree-twitter-perf-opt

Conversation

@guanbinrui

@guanbinrui guanbinrui commented Sep 5, 2026

Copy link
Copy Markdown
Member

Summary

  • The x.com timeline post collector re-runs collectPostInfo/collectLinks on every onNodeMutation of 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-new untilElementAvailable polling watcher + re-parsed images from scratch, and re-requested resolveTCOLink (a cross-context message) for every link in the tweet, even when nothing relevant had changed. Added two small caches — image-parsing promise keyed by tweetNode (WeakMap, auto-GC'd), and resolved t.co link keyed by href (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, and injectTipsButtonOnFollowButton each created a fresh DOMProxy() inside their onNodeMutation/onTargetChanged handler for the same watched element. A new DOMProxy always 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 old remover was 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 existing remove() before creating the new tree, matching the pattern already used correctly elsewhere (e.g. MaskIcon's helper reuses the watcher-provided DOMProxy instead of creating a new one).
  • Output values and the existing watcher/polling architecture are unchanged; this only removes redundant recomputation and a duplicate-tree leak that were contributing to noticeable scroll jank with the extension enabled.

Test plan

  • Manual verification by @guanbinrui: loaded the extension, browsed x.com timeline — scroll jank noticeably improved, no regressions observed (mask icon injection, decrypted posts, image-embedded payloads, link previews/expansion, avatar decorations, tips buttons all still work as before)
  • Confirm no build/type errors (pnpm build / pnpm lint)

🤖 Generated with Claude Code

https://claude.ai/code/session_012hAmnQwwcfT7DaWAAugciS

guanbinrui and others added 2 commits September 5, 2026 13:19
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
@guanbinrui
guanbinrui merged commit 2f4e22b into develop Sep 5, 2026
12 checks passed
@guanbinrui
guanbinrui deleted the worktree-twitter-perf-opt branch September 5, 2026 06:08
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant