Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save ivan/62076b70eb7c03df95399baf2f46e67a to your computer and use it in GitHub Desktop.

Select an option

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
// ==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