Last active
July 8, 2026 04:14
-
-
Save ivan/62076b70eb7c03df95399baf2f46e67a to your computer and use it in GitHub Desktop.
Prevent https://claude.ai from unloading text from the DOM when it is outside the viewport
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
| // ==UserScript== | |
| // @name leave my text alone (claude.ai) | |
| // @namespace https://claude.ai/ | |
| // @version 1.0.0 | |
| // @description Keep every chat message mounted in the DOM (defeats list virtualization) so find-in-page and SingleFile capture whole conversations. | |
| // @match https://claude.ai/* | |
| // @run-at document-idle | |
| // @grant none | |
| // @inject-into page | |
| // @noframes | |
| // ==/UserScript== | |
| // Model-output: Claude Fable 5 | |
| /* | |
| * claude.ai's transcript is a TanStack Virtual list: only rows within | |
| * [firstVisible - overscan, lastVisible + overscan] stay mounted; the rest | |
| * are removed from the DOM. This script locates each live Virtualizer via | |
| * React fiber internals and wraps its setOptions so every options update | |
| * (the app re-applies options on every render) carries an effectively | |
| * infinite overscan, which the range extractor clamps to [0, count - 1] — | |
| * i.e. every row, always mounted. | |
| * | |
| * MUST run in the page's main world: the __reactFiber$ expandos this relies | |
| * on are invisible from an extension's isolated world. Violentmonkey honors | |
| * @inject-into page; Tampermonkey injects into the page when @grant is none; | |
| * as an extension content script, declare "world": "MAIN" in manifest.json. | |
| */ | |
| (() => { | |
| "use strict"; | |
| const FULL_OVERSCAN = 1e9; | |
| const PATCH_FLAG = "__leave_my_text_alone__"; | |
| const SCAN_DEBOUNCE_MS = 300; | |
| /** | |
| * Throw with a recognizable prefix when an invariant is violated. | |
| * @param {boolean} condition - the invariant that must hold. | |
| * @param {string} message - what was violated, for the console. | |
| */ | |
| function assert(condition, message) { | |
| if (!condition) { | |
| throw new Error("leave_my_text_alone: " + message); | |
| } | |
| } | |
| /** | |
| * Fetch the React fiber that React stashes on a host DOM node under a | |
| * randomized expando key ("__reactFiber$<hash>"). | |
| * @param {Element} el - a DOM element rendered by React. | |
| * @returns {object|null} the fiber for `el`, or null if `el` isn't React-managed. | |
| */ | |
| function fiber_of(el) { | |
| assert(el instanceof Element, "fiber_of expects a DOM element"); | |
| const key = Object.keys(el).find((k) => k.startsWith("__reactFiber$")); | |
| return key === undefined ? null : el[key]; | |
| } | |
| /** | |
| * Duck-type check for a TanStack Virtualizer instance. getVirtualItems, | |
| * setOptions, and options are public instance properties, so their names | |
| * survive minification even across claude.ai's hashed bundle chunks. | |
| * @param {*} v - any value pulled out of a hook's state. | |
| * @returns {boolean} true when `v` looks like a Virtualizer. | |
| */ | |
| function is_virtualizer(v) { | |
| return v !== null && typeof v === "object" | |
| && typeof v.getVirtualItems === "function" | |
| && typeof v.setOptions === "function" | |
| && typeof v.options === "object"; | |
| } | |
| /** | |
| * Scan one fiber's hook list (a linked list hanging off memoizedState) | |
| * for Virtualizer instances held in useState/useRef hooks. | |
| * @param {object} fiber - a React fiber node. | |
| * @returns {object[]} every Virtualizer found in this fiber's hooks. | |
| */ | |
| function virtualizers_in(fiber) { | |
| const found = []; | |
| let hook = fiber.memoizedState; | |
| let steps = 0; | |
| while (hook !== null && hook !== undefined && typeof hook === "object" && steps < 10000) { | |
| if (is_virtualizer(hook.memoizedState)) { | |
| found.push(hook.memoizedState); | |
| } | |
| hook = "next" in hook ? hook.next : null; | |
| steps += 1; | |
| } | |
| assert(steps < 10000, "hook chain did not terminate — bailing out"); | |
| return found; | |
| } | |
| /** | |
| * Walk from a DOM node up through its fiber ancestry, collecting every | |
| * Virtualizer owned by an ancestor component (the list component's hooks | |
| * live a few fibers above the row elements it renders). | |
| * @param {Element} el - an element inside (or at the root of) the list. | |
| * @returns {object[]} deduplicated Virtualizer instances, innermost first. | |
| */ | |
| function find_virtualizers(el) { | |
| const found = new Set(); | |
| for (let f = fiber_of(el); f !== null && f !== undefined; f = f.return) { | |
| for (const v of virtualizers_in(f)) { | |
| found.add(v); | |
| } | |
| } | |
| return [...found]; | |
| } | |
| /** | |
| * Make one Virtualizer render everything, permanently. The app calls | |
| * setOptions with freshly rebuilt options on every render, so mutating | |
| * v.options directly would be clobbered one frame later; wrapping | |
| * setOptions makes the override sticky. notify(false) fires the app's | |
| * onChange, which schedules the React re-render that mounts the rows. | |
| * @param {object} v - a TanStack Virtualizer instance. | |
| * @returns {boolean} true if patched now, false if it already was. | |
| */ | |
| function unleash(v) { | |
| if (v[PATCH_FLAG] === true) { | |
| return false; | |
| } | |
| const original_set_options = v.setOptions; | |
| v.setOptions = (opts) => original_set_options({ ...opts, overscan: FULL_OVERSCAN }); | |
| v[PATCH_FLAG] = true; | |
| v.setOptions({ ...v.options }); | |
| assert(v.options.overscan === FULL_OVERSCAN, "overscan override did not stick"); | |
| v.notify(false); | |
| return true; | |
| } | |
| /** | |
| * Pick one representative DOM anchor per virtualized list currently in | |
| * the document. Message lists are tagged data-find-provider-scope; if | |
| * that attribute ever disappears in a refactor, fall back to grouping | |
| * raw data-index rows by their shared sizer parent. | |
| * @returns {Element[]} one anchor element per distinct list. | |
| */ | |
| function list_anchors() { | |
| const scopes = [...document.querySelectorAll("[data-find-provider-scope]")]; | |
| if (scopes.length > 0) { | |
| return scopes.map((s) => s.querySelector("[data-index]") ?? s); | |
| } | |
| const parents = new Set([...document.querySelectorAll("[data-index]")].map((r) => r.parentElement)); | |
| return [...parents].filter((p) => p !== null).map((p) => p.firstElementChild ?? p); | |
| } | |
| /** | |
| * One pass: patch every unpatched Virtualizer reachable from the lists | |
| * currently mounted. Quiet no-op when nothing is mounted yet (fresh | |
| * navigation, empty chat) — this runs continuously, so no throwing on | |
| * "not ready". | |
| * @returns {number} how many instances were newly patched. | |
| */ | |
| function scan() { | |
| let patched = 0; | |
| for (const anchor of list_anchors()) { | |
| for (const v of find_virtualizers(anchor)) { | |
| if (unleash(v)) { | |
| patched += 1; | |
| console.info("leave_my_text_alone: patched a virtualizer;", | |
| v.getVirtualItems().length + "/" + v.options.count, "rows mounted"); | |
| } | |
| } | |
| } | |
| return patched; | |
| } | |
| /** | |
| * Install a debounced MutationObserver so lists mounted later (SPA | |
| * navigation between conversations, lazy-loaded chunks, cowork sessions) | |
| * are patched as they appear, then run one immediate scan. Idempotent: | |
| * already-patched instances are skipped via PATCH_FLAG. | |
| */ | |
| function main() { | |
| let timer = null; | |
| const debounced_scan = () => { | |
| if (timer !== null) { | |
| clearTimeout(timer); | |
| } | |
| timer = setTimeout(() => { | |
| timer = null; | |
| scan(); | |
| }, SCAN_DEBOUNCE_MS); | |
| }; | |
| new MutationObserver(debounced_scan).observe(document.documentElement, { childList: true, subtree: true }); | |
| scan(); | |
| } | |
| main(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment