Created
July 8, 2026 04:32
-
-
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
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 (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