From 6181f293ad6aa477914b56cc36a09dd3741a4d3b Mon Sep 17 00:00:00 2001 From: "yashika.1221" Date: Sat, 11 Apr 2026 23:38:09 +0530 Subject: [PATCH 1/2] tenders lazy-loading --- .../notifications/tenders/TendersList.tsx | 2 + .../tenders/TendersPageClient.tsx | 27 +++-- components/lazy/DynamicLazy.tsx | 46 ++++++++ components/lazy/LazyLoadBoundary.tsx | 105 ++++++++++++++++++ components/lazy/examples.tsx | 57 ++++++++++ components/lazy/useIdleCallback.ts | 31 ++++++ components/lazy/useIntersectionObserver.ts | 41 +++++++ 7 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 components/lazy/DynamicLazy.tsx create mode 100644 components/lazy/LazyLoadBoundary.tsx create mode 100644 components/lazy/examples.tsx create mode 100644 components/lazy/useIdleCallback.ts create mode 100644 components/lazy/useIntersectionObserver.ts diff --git a/app/[locale]/notifications/tenders/TendersList.tsx b/app/[locale]/notifications/tenders/TendersList.tsx index cd0229d41..0b9d03998 100644 --- a/app/[locale]/notifications/tenders/TendersList.tsx +++ b/app/[locale]/notifications/tenders/TendersList.tsx @@ -27,6 +27,8 @@ interface TendersListProps { deletingId?: number | null; } +export default TendersList; + export function TendersList({ tenders, locale, diff --git a/app/[locale]/notifications/tenders/TendersPageClient.tsx b/app/[locale]/notifications/tenders/TendersPageClient.tsx index 903c5dfab..97ab3f1d7 100644 --- a/app/[locale]/notifications/tenders/TendersPageClient.tsx +++ b/app/[locale]/notifications/tenders/TendersPageClient.tsx @@ -1,9 +1,11 @@ 'use client'; +import React, { Suspense } from 'react'; import Link from 'next/link'; import { useState } from 'react'; import { FaPlus } from 'react-icons/fa'; +// Removed stray comment import { Button } from '~/components/buttons'; import { deleteTenderAction, @@ -11,7 +13,7 @@ import { } from '~/server/actions/tenders'; import type { TendersTranslations } from '~/i18n/translate/tenders'; -import { TendersList } from './TendersList'; +import TendersList from './TendersList'; interface TendersPageClientProps { allTenders: TenderWithStatus[]; @@ -41,7 +43,6 @@ export function TendersPageClient({ // Handle delete - updates state for both tabs const handleDelete = async (id: number) => { if (!confirm(text.confirmDelete)) return; - setDeletingId(id); try { const result = await deleteTenderAction(id); @@ -58,6 +59,8 @@ export function TendersPageClient({ } }; + // Removed duplicate import statement for Link + // import Link from 'next/link'; return (
{/* Header with Add button */} @@ -99,15 +102,17 @@ export function TendersPageClient({ {/* Tenders List */} - + Loading tenders...}> + +
); } diff --git a/components/lazy/DynamicLazy.tsx b/components/lazy/DynamicLazy.tsx new file mode 100644 index 000000000..ef732bde0 --- /dev/null +++ b/components/lazy/DynamicLazy.tsx @@ -0,0 +1,46 @@ +import React, { + type ComponentType, + lazy, + type LazyExoticComponent, + Suspense, +} from 'react'; + +export interface DynamicLazyOptions { + fallback?: React.ReactNode; +} + +export interface DynamicLazyComponent

{ + (props: P): JSX.Element; + preload: () => Promise; +} + +export function DynamicLazy

( + loader: () => Promise<{ default: React.ComponentType

}>, + options?: DynamicLazyOptions +): DynamicLazyComponent

{ + let LoadedComponent: LazyExoticComponent> | null = null; + let loadPromise: Promise | null = null; + + function preload(): Promise { + if (!loadPromise) { + loadPromise = loader().then((mod) => { + LoadedComponent = lazy(() => Promise.resolve(mod)); + }); + } + return loadPromise ?? Promise.resolve(); + } + + const LazyComponent = (props: P) => { + if (!LoadedComponent) { + LoadedComponent = lazy(loader); + } + const Component = LoadedComponent; + return ( + + )} /> + + ); + }; + (LazyComponent as DynamicLazyComponent

).preload = preload; + return LazyComponent as DynamicLazyComponent

; +} diff --git a/components/lazy/LazyLoadBoundary.tsx b/components/lazy/LazyLoadBoundary.tsx new file mode 100644 index 000000000..cb72251d4 --- /dev/null +++ b/components/lazy/LazyLoadBoundary.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { useIntersectionObserver } from './useIntersectionObserver'; +import { useIdleCallback } from './useIdleCallback'; + +export type LazyLoadTrigger = 'viewport' | 'interaction' | 'immediate'; + +export interface LazyLoadBoundaryProps { + trigger?: LazyLoadTrigger; + fallback?: React.ReactNode; + children: React.ReactNode; + rootMargin?: string; + threshold?: number; + preloadOnHover?: boolean; + preloadOnIdle?: boolean; + keepMounted?: boolean; + onPreload?: () => void; +} + +export const LazyLoadBoundary: React.FC = ({ + trigger = 'viewport', + fallback = null, + children, + rootMargin = '0px', + threshold = 0, + preloadOnHover = false, + preloadOnIdle = false, + keepMounted = false, + onPreload, +}) => { + const [mounted, setMounted] = useState(trigger === 'immediate'); + const [hasInteracted, setHasInteracted] = useState(false); + const [ref, inView] = useIntersectionObserver({ rootMargin, threshold }); + const hasMountedRef = useRef(false); + + // Preload on hover + const handleMouseEnter = useCallback(() => { + if (preloadOnHover && onPreload) onPreload(); + if (trigger === 'interaction') setHasInteracted(true); + }, [preloadOnHover, onPreload, trigger]); + + // Preload on idle + useIdleCallback(() => { + if (preloadOnIdle && onPreload) onPreload(); + }, [preloadOnIdle, onPreload]); + + // Mount logic + useEffect(() => { + if (trigger === 'immediate') { + setMounted(true); + } else if (trigger === 'viewport' && inView) { + setMounted(true); + } else if (trigger === 'interaction' && hasInteracted) { + setMounted(true); + } + }, [trigger, inView, hasInteracted]); + + // Keep mounted + useEffect(() => { + if (mounted && keepMounted) { + hasMountedRef.current = true; + } + }, [mounted, keepMounted]); + + // Clean up interaction state if not keepMounted + useEffect(() => { + return () => { + if (!keepMounted) { + setMounted(false); + setHasInteracted(false); + } + }; + }, [keepMounted]); + + // Render logic + if (mounted || hasMountedRef.current) { + return ( +

) + : undefined + } + > + {children} +
+ ); + } + return ( +
) + : undefined + } + onClick={ + trigger === 'interaction' ? () => setHasInteracted(true) : undefined + } + onMouseEnter={handleMouseEnter} + style={{ width: '100%', height: '100%' }} + > + {fallback} +
+ ); +}; diff --git a/components/lazy/examples.tsx b/components/lazy/examples.tsx new file mode 100644 index 000000000..8b8371e1c --- /dev/null +++ b/components/lazy/examples.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +import type { CarouselProps } from '~/components/carousels'; + +// import type { CarouselProps } from '~/components/carousels'; +import { LazyLoadBoundary } from './LazyLoadBoundary'; +import { DynamicLazy } from './DynamicLazy'; + +// Match the actual EventItem type from event-section.tsx +interface EventItem { + title: string; + categories?: string[]; + startDate: string | Date; + endDate?: string | Date; + time?: string; + description: string; + images: string[]; + location?: string; + id: number; +} + +interface EventSectionProps extends React.JSX.IntrinsicAttributes { + events: EventItem[]; + locale: string; + showViewAll?: boolean; + viewAllText?: string; + viewAllHref?: string; +} + +const EventsGallery = DynamicLazy(() => + import('../events/event-section').then((mod) => ({ default: mod.default })) +); + +interface GalleryCarouselProps extends React.JSX.IntrinsicAttributes { + carouselProps?: CarouselProps; + children: React.ReactNode[]; + className?: string; + itemClassName?: string; +} + +const GalleryBelowFold = DynamicLazy(() => + import('../carousels/gallery').then((mod) => ({ + default: mod.GalleryCarousel, + })) +); + +export function ExampleEventsGallery() { + return ( + Loading Gallery...} + > + + + ); + // All examples above demonstrate different trigger modes and usage patterns. +} diff --git a/components/lazy/useIdleCallback.ts b/components/lazy/useIdleCallback.ts new file mode 100644 index 000000000..360c1c0f8 --- /dev/null +++ b/components/lazy/useIdleCallback.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; + +interface WindowWithIdleCallback extends Window { + requestIdleCallback: (callback: IdleRequestCallback) => number; + cancelIdleCallback: (id: number) => void; + setTimeout: (callback: () => void, ms: number) => number; + clearTimeout: (id: number) => void; +} + +export function useIdleCallback( + cb: () => void, + _deps: React.DependencyList = [] +) { + useEffect(() => { + let id: number | null = null; + if ('requestIdleCallback' in window) { + id = (window as WindowWithIdleCallback).requestIdleCallback(cb); + } else { + id = (window as WindowWithIdleCallback).setTimeout(cb, 200); + } + return () => { + if (id !== null) { + if ('cancelIdleCallback' in window) { + (window as WindowWithIdleCallback).cancelIdleCallback(id); + } else { + clearTimeout(id); + } + } + }; + }, [cb]); +} diff --git a/components/lazy/useIntersectionObserver.ts b/components/lazy/useIntersectionObserver.ts new file mode 100644 index 000000000..67b1b08d6 --- /dev/null +++ b/components/lazy/useIntersectionObserver.ts @@ -0,0 +1,41 @@ +import { useEffect, useRef, useState } from 'react'; + +export interface UseIntersectionObserverOptions { + root?: Element | null; + rootMargin?: string; + threshold?: number | number[]; + freezeOnceVisible?: boolean; +} + +export function useIntersectionObserver( + options: UseIntersectionObserverOptions = {} +): [React.RefObject, boolean] { + const { + root = null, + rootMargin = '0px', + threshold = 0, + freezeOnceVisible = true, + } = options; + const ref = useRef(null); + const [isIntersecting, setIntersecting] = useState(false); + const frozen = isIntersecting && freezeOnceVisible; + + useEffect(() => { + if (frozen) return; + const node = ref.current; + if (!node) return; + let observer: IntersectionObserver | null = null; + observer = new IntersectionObserver( + ([entry]) => { + setIntersecting(entry.isIntersecting); + }, + { root, rootMargin, threshold } + ); + observer.observe(node); + return () => { + observer && observer.disconnect(); + }; + }, [root, rootMargin, threshold, frozen]); + + return [ref, isIntersecting]; +} From 3e3d6954b02c3b4b61c985dfc83cad45b129a9d7 Mon Sep 17 00:00:00 2001 From: Debatreya Date: Mon, 13 Apr 2026 16:10:18 +0530 Subject: [PATCH 2/2] fix: Tenders Lazy loading --- .../tenders/TendersPageClient.tsx | 51 +++++++++++++------ app/[locale]/notifications/tenders/page.tsx | 17 +++---- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/app/[locale]/notifications/tenders/TendersPageClient.tsx b/app/[locale]/notifications/tenders/TendersPageClient.tsx index 97ab3f1d7..29bc3b863 100644 --- a/app/[locale]/notifications/tenders/TendersPageClient.tsx +++ b/app/[locale]/notifications/tenders/TendersPageClient.tsx @@ -1,19 +1,38 @@ 'use client'; -import React, { Suspense } from 'react'; +import React from 'react'; import Link from 'next/link'; import { useState } from 'react'; import { FaPlus } from 'react-icons/fa'; -// Removed stray comment import { Button } from '~/components/buttons'; +import { DynamicLazy } from '~/components/lazy/DynamicLazy'; import { deleteTenderAction, type TenderWithStatus, } from '~/server/actions/tenders'; import type { TendersTranslations } from '~/i18n/translate/tenders'; -import TendersList from './TendersList'; +type LazyTendersListProps = React.JSX.IntrinsicAttributes & { + tenders: TenderWithStatus[]; + locale: string; + canManage: boolean; + text: TendersTranslations; + isArchived: boolean; + onDelete?: (id: number) => Promise; + deletingId?: number | null; +}; + +const LazyTendersList = DynamicLazy( + () => import('./TendersList').then((mod) => ({ default: mod.default })), + { + fallback: ( +
+ Loading tenders... +
+ ), + } +); interface TendersPageClientProps { allTenders: TenderWithStatus[]; @@ -59,8 +78,6 @@ export function TendersPageClient({ } }; - // Removed duplicate import statement for Link - // import Link from 'next/link'; return (
{/* Header with Add button */} @@ -81,6 +98,8 @@ export function TendersPageClient({
{/* Tenders List */} - Loading tenders...}> - - +
); } diff --git a/app/[locale]/notifications/tenders/page.tsx b/app/[locale]/notifications/tenders/page.tsx index 66eda4df2..5d1100cb6 100644 --- a/app/[locale]/notifications/tenders/page.tsx +++ b/app/[locale]/notifications/tenders/page.tsx @@ -1,9 +1,6 @@ -import { Suspense } from 'react'; - import { getTranslations } from '~/i18n/translations'; import { canManageNotifications, getServerAuthSession } from '~/server/auth'; import ImageHeader from '~/components/image-header'; -import Loading from '~/components/loading'; import { getAllTenders } from '~/server/actions/tenders'; import { TendersPageClient } from './TendersPageClient'; @@ -29,14 +26,12 @@ export default async function TendersPage({ <> - }> - - + ); }