Reddit Remove Infinite Scroll

Replace infinite scroll with Old Reddit style pagination (25 posts per page) on Reddit feeds.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Reddit Remove Infinite Scroll
// @namespace    https://github.com/cantunborn
// @version      1.16
// @description  Replace infinite scroll with Old Reddit style pagination (25 posts per page) on Reddit feeds.
// @author       cantunborn
// @license      MIT
// @homepageURL  https://github.com/cantunborn/userscripts
// @supportURL   https://github.com/cantunborn/userscripts/issues
// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+CiAgPGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iMzAiIGZpbGw9IiMxYTFhMWIiLz4KICA8dGV4dCB4PSIzMiIgeT0iMzIiIGZvbnQtc2l6ZT0iNjAiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJjZW50cmFsIiBmaWxsPSIjZmY0NTAwIiBmb250LWZhbWlseT0iQXJpYWwsIEhlbHZldGljYSwgc2Fucy1zZXJpZiIgZm9udC13ZWlnaHQ9ImJvbGQiPiYjODczNDs8L3RleHQ+CiAgPGxpbmUgeDE9IjEwIiB5MT0iNTQiIHgyPSI1NCIgeTI9IjEwIiBzdHJva2U9IiNmZjQ1MDAiIHN0cm9rZS13aWR0aD0iNSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+Cjwvc3ZnPgo=
// @match        https://www.reddit.com/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  function log(...args) {
    console.log('[rp]', ...args);
  }

  function isBlockedSentinel(el) {
    return !!(el && el.tagName === 'FACEPLATE-PARTIAL' && el.getAttribute('slot') === 'load-after');
  }

  function fakeHiddenEntry(entry) {
    return {
      isIntersecting: false,
      intersectionRatio: 0,
      target: entry.target,
      time: entry.time,
      boundingClientRect: entry.boundingClientRect,
      intersectionRect: entry.intersectionRect,
      rootBounds: entry.rootBounds,
    };
  }

  const NativeIntersectionObserver = window.IntersectionObserver;
  if (NativeIntersectionObserver) {
    window.IntersectionObserver = function (callback, options) {
      function wrappedCallback(entries, observer) {
        const patched = entries.map((e) => {
          if (!isBlockedSentinel(e.target)) return e;
          return fakeHiddenEntry(e);
        });
        callback(patched, observer);
      }
      return new NativeIntersectionObserver(wrappedCallback, options);
    };
    window.IntersectionObserver.prototype = NativeIntersectionObserver.prototype;
  }

  const PAGE_SIZE = 25;
  const LOAD_TIMEOUT_MS = 8000;

  let state = null;
  let lastPath = null;

  const SVG_NS = 'http://www.w3.org/2000/svg';
  const CARET_LEFT_PATH = 'M6.3 10c0-.23.088-.46.264-.636l4.6-4.6a.9.9 0 111.273 1.272L8.474 10l3.963 3.964a.9.9 0 01-1.273 1.272l-4.6-4.6A.897.897 0 016.3 10Z';
  const CARET_RIGHT_PATH = 'M13.7 10c0 .23-.088.46-.264.636l-4.6 4.6a.9.9 0 11-1.273-1.272L11.526 10 7.563 6.036a.9.9 0 011.273-1.272l4.6 4.6A.897.897 0 0113.7 10Z';

  function makeCaretIcon(path) {
    const svg = document.createElementNS(SVG_NS, 'svg');
    svg.setAttribute('fill', 'currentColor');
    svg.setAttribute('height', '16');
    svg.setAttribute('width', '16');
    svg.setAttribute('viewBox', '0 0 20 20');
    svg.style.flexShrink = '0';
    const pathEl = document.createElementNS(SVG_NS, 'path');
    pathEl.setAttribute('d', path);
    svg.appendChild(pathEl);
    return svg;
  }

  function isListingPath(pathname) {
    if (/\/comments\//.test(pathname)) return false;
    if (/^\/(message|settings|submit|premium|chat)/.test(pathname)) return false;
    return true;
  }

  const MAX_SNAPSHOT_BYTES = 3 * 1024 * 1024;

  function navigationType() {
    try {
      const entries = performance.getEntriesByType('navigation');
      if (entries.length) return entries[0].type;
    } catch (err) {
      log('navigationType: check failed', err);
    }
    return 'navigate';
  }

  function storageKeySuffix() {
    return location.pathname + location.search;
  }

  function pageStorageKey() {
    return 'reddit-paginate:page:' + storageKeySuffix();
  }

  function snapshotStorageKey() {
    return 'reddit-paginate:snapshot:' + storageKeySuffix();
  }

  function saveCurrentPage() {
    try {
      sessionStorage.setItem(pageStorageKey(), String(state.currentPage));
    } catch (err) {
      log('saveCurrentPage: failed', err);
    }
  }

  function readSavedPage() {
    try {
      const raw = sessionStorage.getItem(pageStorageKey());
      const n = parseInt(raw, 10);
      return Number.isFinite(n) && n > 1 ? n : null;
    } catch (err) {
      log('readSavedPage: failed', err);
      return null;
    }
  }

  function loadSnapshot() {
    try {
      const raw = sessionStorage.getItem(snapshotStorageKey());
      if (!raw) return [];
      const parsed = JSON.parse(raw);
      return Array.isArray(parsed) ? parsed : [];
    } catch (err) {
      log('loadSnapshot: failed', err);
      return [];
    }
  }

  function appendToSnapshot(post) {
    const key = snapshotStorageKey();
    const entries = loadSnapshot();
    entries.push({ id: post.id, html: wrapperOf(post).outerHTML });
    let serialized = JSON.stringify(entries);
    while (serialized.length > MAX_SNAPSHOT_BYTES && entries.length > 1) {
      entries.shift();
      serialized = JSON.stringify(entries);
    }
    try {
      sessionStorage.setItem(key, serialized);
    } catch (err) {
      log('appendToSnapshot: storage quota exceeded, leaving earlier snapshot in place', err);
    }
  }

  function clearSnapshot() {
    try {
      sessionStorage.removeItem(snapshotStorageKey());
    } catch (err) {
      log('clearSnapshot: failed', err);
    }
  }

  function restoreFromSnapshot(feed) {
    const entries = loadSnapshot();
    if (!entries.length) return null;

    const template = document.createElement('template');
    const restoredWrappers = [];
    const restoredPosts = [];
    for (const entry of entries) {
      template.innerHTML = entry.html;
      const wrapper = template.content.firstElementChild;
      if (!wrapper) continue;
      const post = wrapper.matches('shreddit-post') ? wrapper : wrapper.querySelector('shreddit-post');
      if (!post) continue;
      restoredWrappers.push(wrapper);
      restoredPosts.push(post);
    }
    if (!restoredPosts.length) return null;

    feed.querySelectorAll('shreddit-post').forEach((p) => wrapperOf(p).remove());

    const sentinel = feed.querySelector('faceplate-partial[slot="load-after"]');
    restoredWrappers.forEach((wrapper) => {
      if (sentinel) sentinel.insertAdjacentElement('beforebegin', wrapper);
      else feed.appendChild(wrapper);
    });
    return restoredPosts;
  }

  function wrapperOf(post) {
    return post.closest('article') || post;
  }

  function setVisible(post, visible) {
    const wrapper = wrapperOf(post);
    wrapper.style.display = visible ? '' : 'none';
    const sib = wrapper.nextElementSibling;
    if (sib && sib.tagName === 'HR') sib.style.display = visible ? '' : 'none';
  }

  function teardown() {
    if (state) {
      if (state.observer) state.observer.disconnect();
      if (state.pendingTimer) clearTimeout(state.pendingTimer);
      if (state.bar) state.bar.remove();
      const sentinel = findSentinel();
      if (sentinel) sentinel.style.removeProperty('display');
      state.posts.forEach((p) => {
        p.style.removeProperty('display');
        const sib = wrapperOf(p).nextElementSibling;
        if (sib && sib.tagName === 'HR') sib.style.removeProperty('display');
      });
    }
    state = null;
  }

  function scheduleInit() {
    const prevFeed = state ? state.feed : null;
    const prevFirstPostId = state && state.posts[0] ? state.posts[0].id : null;
    teardown();
    if (!isListingPath(location.pathname)) return;

    let tries = 0;
    const tryInit = () => {
      const feed = document.querySelector('shreddit-feed');
      const posts = feed ? Array.from(feed.querySelectorAll('shreddit-post')) : [];
      const stale = feed && feed === prevFeed && posts[0] && posts[0].id === prevFirstPostId;
      if (feed && posts.length > 0 && !stale) {
        init(feed, posts);
        return;
      }
      tries++;
      if (tries < 30) {
        setTimeout(tryInit, 300);
      } else {
        log('scheduleInit: gave up after', tries, 'tries, feed:', !!feed, 'posts:', posts.length);
      }
    };
    tryInit();
  }

  async function init(feed, posts) {
    log('init: starting with', posts.length, 'posts');
    const navType = navigationType();

    state = {
      feed,
      posts: [],
      currentPage: 1,
      hasMore: true,
      loading: false,
      pendingTimer: null,
      pendingResolve: null,
      bar: null,
      observer: null,
    };

    log('init: navType is', navType, 'live posts seen so far', posts.length);
    const restoredPosts = navType === 'back_forward' ? restoreFromSnapshot(feed) : null;
    log('init: restoreFromSnapshot returned', restoredPosts ? restoredPosts.length + ' posts' : 'null');
    if (!restoredPosts) {
      clearSnapshot();
      posts.forEach((p) => registerPost(p));
    } else {
      state.posts = restoredPosts;
    }

    state.observer = new MutationObserver((mutations) => {
      let added = false;
      for (const m of mutations) {
        for (const node of m.addedNodes) {
          if (node.nodeType !== 1) continue;
          if (node.tagName === 'SHREDDIT-POST') {
            if (registerPost(node)) added = true;
          } else if (node.querySelectorAll) {
            node.querySelectorAll('shreddit-post').forEach((p) => {
              if (registerPost(p)) added = true;
            });
          }
        }
      }
      if (added) {
        applyVisibility();
        if (state.pendingResolve) {
          clearTimeout(state.pendingTimer);
          const resolve = state.pendingResolve;
          state.pendingResolve = null;
          resolve();
        }
      }
    });
    state.observer.observe(feed, { childList: true, subtree: true });

    const savedPage = navType === 'back_forward' ? readSavedPage() : null;
    const target = savedPage || 1;
    log('init: savedPage is', savedPage, 'target is', target);
    await ensureLoadedThrough(target);
    if (state.posts.length > (target - 1) * PAGE_SIZE) {
      state.currentPage = target;
    }
    log('init: done, currentPage is', state.currentPage, 'total posts', state.posts.length);
    reflow();
  }

  function registerPost(node) {
    if (!state || state.posts.some((p) => p.id === node.id)) return false;
    state.posts.push(node);
    appendToSnapshot(node);
    return true;
  }

  function totalKnownPages() {
    return Math.max(1, Math.ceil(state.posts.length / PAGE_SIZE));
  }

  function applyVisibility() {
    const start = (state.currentPage - 1) * PAGE_SIZE;
    const end = start + PAGE_SIZE;
    state.posts.forEach((p, i) => setVisible(p, i >= start && i < end));
  }

  function reflow() {
    applyVisibility();
    renderBars();
  }

  function findSentinel() {
    const matches = state.feed.querySelectorAll('faceplate-partial[slot="load-after"]');
    if (matches.length > 1) {
      log('findSentinel: found', matches.length, 'sentinels, using last:', matches[matches.length - 1].id);
    }
    return matches.length ? matches[matches.length - 1] : null;
  }

  function getDividerBox() {
    const hr = state.feed.querySelector('article + hr') || state.feed.querySelector('hr');
    if (!hr) return null;
    const cs = getComputedStyle(hr);
    return { width: cs.width, marginLeft: cs.marginLeft, marginRight: cs.marginRight };
  }

  function applyDividerBox(el, box) {
    if (!box) return;
    el.style.width = box.width;
    el.style.marginLeft = box.marginLeft;
    el.style.marginRight = box.marginRight;
    el.style.maxWidth = 'none';
  }

  function positionBars() {
    const box = getDividerBox();

    const lastPost = state.posts[state.posts.length - 1];
    const bottomWrapper = lastPost ? wrapperOf(lastPost) : null;
    if (bottomWrapper) {
      bottomWrapper.insertAdjacentElement('afterend', state.bar);
      applyDividerBox(state.bar, box);
    } else {
      state.feed.appendChild(state.bar);
    }
    hideTrailingElements();
  }

  function hideTrailingElements() {
    if (!state.bar || !state.bar.isConnected) return;
    let sib = state.bar.nextElementSibling;
    while (sib) {
      sib.style.display = 'none';
      sib = sib.nextElementSibling;
    }
  }

  function waitForMorePosts(timeoutMs) {
    return new Promise((resolve) => {
      state.pendingResolve = resolve;
      state.pendingTimer = setTimeout(() => {
        state.pendingResolve = null;
        resolve();
      }, timeoutMs);
    });
  }

  async function requestNextBatch() {
    const sentinel = findSentinel();
    if (!sentinel) {
      log('requestNextBatch: no sentinel found, stopping');
      state.hasMore = false;
      return;
    }
    const before = state.posts.length;
    log('requestNextBatch: calling loadContent on', sentinel.id, 'before count', before);
    const waitPromise = waitForMorePosts(LOAD_TIMEOUT_MS);
    try {
      sentinel.loadContent();
    } catch (err) {
      log('requestNextBatch: loadContent threw', err);
    }
    await waitPromise;
    log('requestNextBatch: after count', state.posts.length);
    if (state.posts.length === before) {
      log('requestNextBatch: count unchanged, marking hasMore false');
      state.hasMore = false;
    }
  }

  async function ensureLoadedThrough(page) {
    const needed = page * PAGE_SIZE;
    while (state.posts.length < needed && state.hasMore) {
      state.loading = true;
      renderBars();
      await requestNextBatch();
    }
    state.loading = false;
    log('ensureLoadedThrough: done, have', state.posts.length, 'hasMore', state.hasMore);
  }

  function makeButton(label, enabled, onClick) {
    const el = document.createElement('button');
    el.type = 'button';
    const labelSpan = document.createElement('span');
    labelSpan.className = 'flex items-center gap-xs';
    labelSpan.textContent = label;
    el.appendChild(labelSpan);
    el.className = 'button button-small leading-none inline-flex items-center px-sm py-xs button-secondary';
    el.style.fontSize = 'var(--font-label-2-size, 12px)';
    if (!enabled) {
      el.disabled = true;
    } else {
      el.addEventListener('click', onClick);
    }
    return el;
  }

  function wrapIcon(svg, leading) {
    const wrap = document.createElement('span');
    wrap.className = leading ? 'flex me-2xs ms-[-2px]' : 'flex ms-2xs me-[-2px]';
    wrap.appendChild(svg);
    return wrap;
  }

  function buildPageLabel() {
    const el = document.createElement('span');
    el.className = 'whitespace-nowrap text-neutral-content-weak flex items-center';
    el.textContent = 'page ' + state.currentPage;
    return el;
  }

  function buildBarContents(barEl) {
    barEl.innerHTML = '';

    const prevBtn = makeButton('prev', state.currentPage > 1, goPrev);
    prevBtn.insertBefore(wrapIcon(makeCaretIcon(CARET_LEFT_PATH), true), prevBtn.firstChild);
    barEl.appendChild(prevBtn);

    barEl.appendChild(buildPageLabel());

    const canAdvance = state.hasMore || state.posts.length > state.currentPage * PAGE_SIZE;
    const nextBtn = makeButton(
      state.loading ? 'loading…' : 'next',
      canAdvance && !state.loading,
      goNext
    );
    if (!state.loading) {
      nextBtn.appendChild(wrapIcon(makeCaretIcon(CARET_RIGHT_PATH), false));
    }
    barEl.appendChild(nextBtn);

    if (!canAdvance) {
      const end = document.createElement('span');
      end.textContent = 'end of feed';
      applyEndLabelStyles(end);
      barEl.appendChild(end);
    }
  }

  function renderBars() {
    if (!state.bar) {
      state.bar = document.createElement('div');
      state.bar.setAttribute('data-reddit-paginate', 'bar');
      state.bar.className = 'gap-xs';
      applyBarStyles(state.bar);
    }
    positionBars();
    buildBarContents(state.bar);
  }

  function scrollToTopOfFeed() {
    const top = state.feed.getBoundingClientRect().top + window.scrollY - 8;
    window.scrollTo({ top, behavior: 'instant' });
  }

  function goPrev() {
    if (state.currentPage <= 1) return;
    state.currentPage--;
    saveCurrentPage();
    reflow();
    scrollToTopOfFeed();
  }

  async function goNext() {
    if (state.loading) return;
    const target = state.currentPage + 1;
    await ensureLoadedThrough(target);

    if (state.posts.length > (target - 1) * PAGE_SIZE) {
      state.currentPage = target;
      saveCurrentPage();
    }
    reflow();
    scrollToTopOfFeed();
  }

  function applyBarStyles(el) {
    el.style.display = 'flex';
    el.style.flexWrap = 'nowrap';
    el.style.alignItems = 'center';
    el.style.justifyContent = 'center';
    el.style.width = '100%';
    el.style.maxWidth = '100%';
    el.style.boxSizing = 'border-box';
    el.style.padding = '16px 8px';
    el.style.fontFamily = 'inherit';
    el.style.fontSize = 'var(--font-label-2-size, 14px)';
  }

  function applyEndLabelStyles(el) {
    el.style.marginLeft = '8px';
    el.style.color = 'var(--rp-fg-disabled)';
    el.style.fontSize = 'var(--font-label-2-size, 13px)';
  }

  function injectThemeVars() {
    if (document.getElementById('reddit-paginate-vars')) return;
    const style = document.createElement('style');
    style.id = 'reddit-paginate-vars';
    style.textContent = `
      :root {
        --rp-fg-disabled: var(--color-neutral-content-weak, #9b9ba1);
      }
      faceplate-partial[slot="load-after"] {
        display: none !important;
      }
    `;
    document.head.appendChild(style);
  }

  function watchNavigation() {
    lastPath = location.pathname + location.search;
    setInterval(() => {
      const currentPath = location.pathname + location.search;
      const pathChanged = currentPath !== lastPath;
      const feedGone = state && !state.feed.isConnected;
      if (pathChanged || feedGone) {
        lastPath = currentPath;
        scheduleInit();
      }
    }, 500);
  }

  function start() {
    injectThemeVars();
    scheduleInit();
    watchNavigation();
  }

  if (typeof module !== 'undefined' && module.exports) {
    // Test-only export. A userscript manager never defines `module`, so this
    // branch never runs in the browser, and `start()` never auto-runs under test.
    module.exports = {
      PAGE_SIZE,
      MAX_SNAPSHOT_BYTES,
      navigationType,
      pageStorageKey,
      snapshotStorageKey,
      saveCurrentPage,
      readSavedPage,
      loadSnapshot,
      appendToSnapshot,
      clearSnapshot,
      restoreFromSnapshot,
      registerPost,
      scheduleInit,
      watchNavigation,
      totalKnownPages,
      applyVisibility,
      wrapperOf,
      __setState: (s) => { state = s; },
      __getState: () => state,
    };
  } else if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', start);
  } else {
    start();
  }
})();