TryGhost / TryGhost/Themes

Ruby displays duplicate posts in infinite scroll - have an update, but main.js in Ruby is somewhat terse

Open
#384 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Handlebars
Stars
97
Forks
84
Avg merge
1h 8m
Merged PRs (30d)
35

Description

Running Ruby on a installation with 400+ posts, and get duplicates in the infinite scroll.
I have an update to main.js which resolves it.
Ruby's main.js looks like this in the repo, I'm a little lost on how to create a branch here..

Image

Here is my main.js which uses a Set() to track post ID and prevent duplicates, feel free to merge it.

(() => {
    // Track loaded post IDs to prevent duplicates
    const loadedPosts = new Set();

    // Initialize pagination
    pagination(true);

    // Handle mobile menu
    const burger = document.querySelector('.gh-burger');
    if (burger) {
        burger.addEventListener('click', function () {
            if (document.body.classList.contains('is-head-open')) {
                document.body.classList.remove('is-head-open');
            } else {
                document.body.classList.add('is-head-open');
            }
        });
    }

    // Initialize lightbox for images
    lightbox('.kg-image-card > .kg-image[width][height], .kg-gallery-image > img');

    // Handle responsive iframes
    reframe(document.querySelectorAll([
        '.gh-content iframe[src*="youtube.com"]',
        '.gh-content iframe[src*="youtube-nocookie.com"]',
        '.gh-content iframe[src*="player.vimeo.com"]',
        '.gh-content iframe[src*="kickstarter.com"][src*="video.html"]',
        '.gh-content object',
        '.gh-content embed'
    ].join(',')));

    // Initialize dropdown
    dropdown();

    /**
     * Enhanced pagination function that handles both infinite scroll and load-more button functionality
     * 
     * @param {boolean} isInitial - If true, sets up infinite scroll using IntersectionObserver.
     *                              If false, uses load-more button functionality.
     * @param {Function} callback - Optional callback function executed after loading new posts.
     *                             Receives (uniquePosts, nextElement) as parameters.
     * @param {boolean} waitForImages - If true, new posts are initially hidden until images load.
     *                                  Helps prevent layout shifts during image loading.
     */
    function pagination(isInitial, callback, waitForImages = false) {
        // Main container for posts
        const container = document.querySelector('.gh-feed');
        if (!container) return;

        // State tracking for preventing concurrent loads
        let isLoading = false;

        // Element used to trigger infinite scroll
        // Falls back through multiple options to find a suitable trigger element
        let nextElement = container.nextElementSibling || container.parentElement.nextElementSibling || document.querySelector('.gh-foot');
        
        // Load more button for manual pagination if infinite scroll is disabled
        const loadMoreButton = document.querySelector('.gh-loadmore');

        // Early cleanup: Remove load more button if there are no more pages to load
        if (!document.querySelector('link[rel=next]')) {
            loadMoreButton?.remove();
            return;
        }

        /**
         * Fetches and appends posts from the next page
         * - Fetches HTML from next page URL
         * - Parses response and extracts posts
         * - Filters out duplicate posts using URL tracking
         * - Appends unique posts to container
         * - Updates pagination links
         * - Handles visibility for image loading
         */
        async function loadNextPage() {
            const nextPageUrl = document.querySelector('link[rel=next]');
            if (!nextPageUrl) return;

            try {
                const response = await fetch(nextPageUrl.href);
                const html = await response.text();
                const parser = new DOMParser();
                const doc = parser.parseFromString(html, 'text/html');
                
                // Extract all posts from next page, excluding featured and related posts
                const newPosts = Array.from(doc.querySelectorAll('.gh-feed:not(.gh-featured):not(.gh-related) > *'));
                
                // Remove any posts that have already been loaded to prevent duplicates
                // Uses post URLs stored in loadedPosts Set for tracking
                const uniquePosts = newPosts.filter(post => {
                    const postLink = post.querySelector('a.post-link');
                    if (!postLink) return false;
                    
                    const postUrl = postLink.href;
                    if (loadedPosts.has(postUrl)) {
                        return false;
                    }
                    
                    loadedPosts.add(postUrl);
                    return true;
                });

                // Use DocumentFragment for better performance when adding multiple posts
                // Optionally hide posts initially if waiting for images to load
                const fragment = document.createDocumentFragment();
                uniquePosts.forEach(post => {
                    const importedPost = document.importNode(post, true);
                    if (waitForImages) {
                        importedPost.style.visibility = 'hidden';
                    }
                    fragment.appendChild(importedPost);
                });
                
                container.appendChild(fragment);

                // Update or remove pagination links based on next page availability
                // Removes load more button when reaching the last page
                const newNextLink = doc.querySelector('link[rel=next]');
                if (newNextLink && newNextLink.href) {
                    nextPageUrl.href = newNextLink.href;
                } else {
                    nextPageUrl.remove();
                    loadMoreButton?.remove();
                }

                // Execute callback with newly loaded posts and next element reference
                if (callback) {
                    callback(uniquePosts, nextElement);
                }

            } catch (error) {
                console.error('Error loading next page:', error);
                nextPageUrl.remove();
                loadMoreButton?.remove();
            }
        }

        /**
         * Handles infinite scroll functionality
         * - Checks if more pages are available
         * - Prevents concurrent loading
         * - Triggers load when next element comes into view
         */
        async function handleScroll() {
            if (!document.querySelector('link[rel=next]')) return;
            
            if (isLoading) return;
            
            if (nextElement.getBoundingClientRect().top <= window.innerHeight) {
                isLoading = true;
                await loadNextPage();
                isLoading = false;
            }
        }

        /**
         * Initialize pagination based on mode (infinite scroll vs load more button)
         * For infinite scroll (isInitial = true):
         * - Sets up IntersectionObserver to detect when more content should load
         * - Handles both immediate and delayed loading based on waitForImages
         * For load more button (isInitial = false):
         * - Attaches click handler to load more button
         */
        if (isInitial) {
            const observer = new IntersectionObserver(async function(entries) {
                if (isLoading) return;
                
                if (entries[0].isIntersecting) {
                    isLoading = true;
                    if (waitForImages) {
                        await loadNextPage();
                    } else {
                        while (nextElement.getBoundingClientRect().top <= window.innerHeight && document.querySelector('link[rel=next]')) {
                            await loadNextPage();
                        }
                    }
                    isLoading = false;
                }
            });

            observer.observe(nextElement);
        } else if (loadMoreButton) {
            loadMoreButton.addEventListener('click', loadNextPage);
        }
    }
})();

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Locate the Ruby theme's main.js and inspect its pagination and infinite-scroll handling, using the reported duplicate posts with 400+ posts as the reproduction case. Compare the existing behavior with the proposed Set-based post URL tracking, then verify that infinite scrolling loads each post once and stops correctly when there are no more pages.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
frontend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.