Skip to content

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

@mheland

Description

@mheland

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);
        }
    }
})();

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions