Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

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

Select an option

Save ivan/e51b8d7e7512d412f22967d3b53aa052 to your computer and use it in GitHub Desktop.
Prevent ChatGPT from unloading text from the DOM when it is outside the viewport
// ==UserScript==
// @name leave my text alone (chatgpt.com)
// @namespace https://chatgpt.com/
// @version 1.0.0
// @description Keep every chat turn mounted in the DOM (defeats IntersectionObserver-driven unloading) so find-in-page and SingleFile capture whole conversations.
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @run-at document-start
// @grant none
// @inject-into page
// @noframes
// ==/UserScript==
// Model-output: Claude Fable 5
/*
* chatgpt.com wraps each conversation turn in a container
* (div[data-turn-id-container]) watched by an IntersectionObserver. When a
* turn leaves the viewport (plus margin), React unmounts its content and
* pins the empty shell to height: var(--last-known-height, 50vh) to keep
* scroll geometry. The visibility state lives in per-turn React state fed
* exclusively by observer callbacks, so the clean interception point is the
* observer itself: replace window.IntersectionObserver before the app loads
* and rewrite any entry targeting a turn container to "fully intersecting".
* React then believes every turn is on screen and never unmounts anything.
* Entries for all other targets (lazy images, scroll sentinels, sidebar
* infinite scroll) pass through untouched.
*
* MUST run at document-start in the page's main world — observers built
* before this script runs cannot be retro-patched (reload the tab if the
* manager injected late). Violentmonkey honors @inject-into page;
* Tampermonkey injects into the page when @grant is none.
*/
(() => {
"use strict";
const TURN_SELECTOR = '[data-turn-id-container], [data-turn-id], article[data-testid^="conversation-turn"]';
const NATIVE_IO = window.IntersectionObserver;
const ENTRY_PROTO = typeof IntersectionObserverEntry !== "undefined" ? IntersectionObserverEntry.prototype : Object.prototype;
let spoof_count = 0;
/**
* 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);
}
}
/**
* Decide whether an observed target is a conversation-turn element whose
* visibility we must lie about. Everything else keeps real entries.
* @param {*} target - the entry's target (usually an Element).
* @returns {boolean} true when `target` is a turn container/section.
*/
function is_turn_target(target) {
return target instanceof Element && target.matches(TURN_SELECTOR);
}
/**
* Build a fake IntersectionObserverEntry that reports the target as
* fully visible. Own data properties shadow the native prototype's
* getters (which would reject a non-native receiver), and using the
* native prototype keeps `entry instanceof IntersectionObserverEntry`
* true for defensive app code.
* @param {IntersectionObserverEntry} entry - the real entry to clone.
* @returns {object} a spoofed entry: isIntersecting true, ratio 1.
*/
function spoof_entry(entry) {
const rect = entry.boundingClientRect;
const props = {
time: entry.time,
target: entry.target,
rootBounds: entry.rootBounds,
boundingClientRect: rect,
intersectionRect: rect,
intersectionRatio: 1,
isIntersecting: true,
isVisible: true,
};
const spoofed = Object.create(ENTRY_PROTO);
for (const key of Object.keys(props)) {
Object.defineProperty(spoofed, key, { value: props[key], enumerable: true });
}
return spoofed;
}
/**
* Rewrite an entry list: spoof entries aimed at turn targets, pass the
* rest through unchanged. Logs once so you can confirm it's active.
* @param {IntersectionObserverEntry[]} entries - entries from the native observer.
* @returns {object[]} same-length list with turn entries replaced.
*/
function launder(entries) {
return entries.map((entry) => {
if (!is_turn_target(entry.target)) {
return entry;
}
spoof_count += 1;
if (spoof_count === 1) {
console.info("leave_my_text_alone: intercepting turn visibility — all turns will stay mounted");
}
return spoof_entry(entry);
});
}
/**
* Install the replacement IntersectionObserver: a subclass of the native
* one whose callback (and takeRecords) only ever sees laundered entries.
* Subclassing preserves instanceof, prototype methods, and options
* semantics (root, rootMargin, thresholds) untouched.
*/
function main() {
assert(typeof NATIVE_IO === "function", "no native IntersectionObserver — nothing to patch");
class patched_io extends NATIVE_IO {
/**
* @param {Function} callback - the app's observer callback.
* @param {object} options - standard IntersectionObserver options.
*/
constructor(callback, options) {
assert(typeof callback === "function", "IntersectionObserver callback must be a function");
super((entries, observer) => callback(launder(entries), observer), options);
}
/**
* @returns {object[]} pending records, laundered like callback entries.
*/
takeRecords() {
return launder(super.takeRecords());
}
}
window.IntersectionObserver = patched_io;
}
main();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment