Skip to content

Conversation

@Ayyub2006
Copy link

@Ayyub2006 Ayyub2006 commented Jan 8, 2026

📋 Summary

This PR implements lazy loading for gallery images to improve performance, reduce memory usage, and enhance the user experience for large image collections.

✨ What’s Changed

  • Added a reusable useIntersectionObserver hook
  • Introduced a LazyImage component with:
    • Skeleton placeholder
    • Fade-in transition
    • Error state handling
  • Integrated lazy loading into:
    • ImageCard
    • MediaThumbnails
  • Ensured gallery containers remain unchanged to preserve existing architecture

🚀 Benefits

  • Faster initial page load
  • Reduced bandwidth and memory usage
  • Smooth scrolling for large galleries
  • No layout shifts during image loading

🧪 Testing

  • Verified lazy loading behavior with large image sets
  • Confirmed thumbnails and main gallery load progressively
  • Tested in both light and dark modes
  • Ensured no TypeScript or linting errors

🔗 Related Issue

Fixed #998

📝 Notes

  • Lazy loading is applied only at leaf image components
  • Error handling is centralized within LazyImage
  • Existing image viewer and click interactions remain unaffected

Summary by CodeRabbit

  • New Features

    • Images now lazy-load as they enter the viewport and show skeleton loaders while loading.
    • Alt text for thumbnails is now derived from image filenames when available.
  • Changes

    • Thumbnail rendering switched to the new lazy-loading image component; prior on-error placeholder fallback removed.
  • Bug Fixes

    • Displays an explicit error state when an image fails to load.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 8, 2026

📝 Walkthrough

Walkthrough

Adds a reusable IntersectionObserver hook and a LazyImage component that defers image loading until visible, shows a skeleton while loading, handles load errors, and applies a fade-in. Replaces img usage with LazyImage in ImageCard and MediaThumbnails.

Changes

Cohort / File(s) Summary
Lazy loading foundation
frontend/src/hooks/useIntersectionObserver.ts, frontend/src/components/LazyImage/LazyImage.tsx
New useIntersectionObserver hook returning visibility boolean; new LazyImage component (props: src, alt, className, rootMargin, threshold) that renders a skeleton, lazy-loads image when visible, handles onLoad/onError, and applies opacity transition.
Integration into media components
ImageCard: frontend/src/components/Media/ImageCard.tsx, MediaThumbnails: frontend/src/components/Media/MediaThumbnails.tsx
Replaced direct <img> with LazyImage and added imports. ImageCard alt text now derived from filename/fallback. MediaThumbnails removed prior onError fallback to placeholder.svg, delegating error handling to LazyImage.

Sequence Diagram

sequenceDiagram
    participant User
    participant BrowserViewport
    participant IntersectionObserver
    participant LazyImage
    participant ImageElement as <img>

    User->>BrowserViewport: Scrolls / navigates
    BrowserViewport->>IntersectionObserver: layout/visibility updates
    IntersectionObserver->>LazyImage: notify visible
    LazyImage->>LazyImage: set isVisible = true
    LazyImage->>ImageElement: set src, attach onLoad/onError
    ImageElement->>ImageElement: fetch resource
    ImageElement->>LazyImage: onLoad / onError
    LazyImage->>LazyImage: set isLoaded or hasError
    LazyImage->>ImageElement: apply fade-in (if loaded) or show error UI
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 I peeked from my burrow, eyes all bright,

Images sleep until you scroll them to light.
A soft little skeleton keeps layout just right,
Then a gentle fade-in — what a delight!
Hop on, lazy pixels, load when in sight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Feat/lazy image loading' clearly and concisely summarizes the main change—implementing lazy loading functionality for images in the application.
Linked Issues check ✅ Passed All coding objectives from issue #998 are met: useIntersectionObserver hook implemented, LazyImage component with skeleton/fade-in/error handling created, integration into ImageCard and MediaThumbnails completed, existing interactions preserved, and no TypeScript/linting issues present.
Out of Scope Changes check ✅ Passed All changes directly support the lazy loading implementation scope; no unrelated modifications to unrelated files or features were introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Contributor

github-actions bot commented Jan 8, 2026

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/components/Media/ImageCard.tsx (1)

58-67: Replace hardcoded alt text with dynamic content.

The alt attribute is hardcoded to 'Sample Title', which doesn't describe the actual image content. This impacts accessibility for screen reader users.

♻️ Proposed fix
          <LazyImage
            src={convertFileSrc(
              image.thumbnailPath || image.path || '/placeholder.svg',
            )}
-            alt={'Sample Title'}
+            alt={image.name || image.path.split('/').pop() || 'Image thumbnail'}
            className={cn(
              'h-full w-full object-cover transition-transform group-hover:scale-105',
              isSelected ? 'opacity-95' : '',
            )}
          />
🧹 Nitpick comments (2)
frontend/src/components/Media/MediaThumbnails.tsx (1)

120-125: Remove trailing whitespace.

Line 124 contains only whitespace. Clean it up for consistency.

🧹 Proposed fix
            <LazyImage
              src={convertFileSrc(image.thumbnailPath) || '/placeholder.svg'}
              alt={`thumbnail-${index}`}
              className="h-full w-full object-cover"
-             
            />
frontend/src/components/LazyImage/LazyImage.tsx (1)

8-8: Remove unused placeholder prop.

The placeholder prop is defined in the interface and destructured but never used in the component's rendering logic. The component uses a generic skeleton loader instead.

♻️ Proposed fix

Remove the unused prop from the interface and function signature:

interface LazyImageProps {
  src: string;
  alt: string;
  className?: string;
-  placeholder?: string;
  rootMargin?: string;
  threshold?: number;
}

export function LazyImage({
  src,
  alt,
  className = "",
-  placeholder,
  rootMargin = "200px",
  threshold = 0.1,
}: LazyImageProps) {

If you plan to use custom placeholder images in the future, you can keep the prop and implement it like this:

      {/* Skeleton loader */}
      {!isLoaded && !hasError && (
-        <div className="absolute inset-0 animate-pulse bg-muted" />
+        placeholder ? (
+          <img src={placeholder} alt="" className="absolute inset-0 h-full w-full object-cover" />
+        ) : (
+          <div className="absolute inset-0 animate-pulse bg-muted" />
+        )
      )}

Also applies to: 17-17

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a3af96c and a992596.

📒 Files selected for processing (5)
  • frontend/src/components/LazyImage/LazyImage.test.tsx
  • frontend/src/components/LazyImage/LazyImage.tsx
  • frontend/src/components/Media/ImageCard.tsx
  • frontend/src/components/Media/MediaThumbnails.tsx
  • frontend/src/hooks/useIntersectionObserver.ts
🧰 Additional context used
🧬 Code graph analysis (3)
frontend/src/components/Media/MediaThumbnails.tsx (1)
frontend/src/components/LazyImage/LazyImage.tsx (1)
  • LazyImage (13-61)
frontend/src/components/LazyImage/LazyImage.tsx (1)
frontend/src/hooks/useIntersectionObserver.ts (1)
  • useIntersectionObserver (12-42)
frontend/src/components/Media/ImageCard.tsx (1)
frontend/src/components/LazyImage/LazyImage.tsx (1)
  • LazyImage (13-61)
🔇 Additional comments (4)
frontend/src/components/Media/ImageCard.tsx (1)

10-10: LGTM! LazyImage import is correct.

The import statement properly references the new LazyImage component.

frontend/src/components/Media/MediaThumbnails.tsx (1)

3-3: LGTM! LazyImage import is correct.

The import properly references the new LazyImage component for thumbnail lazy loading.

frontend/src/hooks/useIntersectionObserver.ts (1)

1-42: LGTM! Hook implementation is correct and efficient.

The useIntersectionObserver hook correctly:

  • Observes the element when it exists and hasn't been seen yet
  • Sets visibility and unobserves immediately on intersection (one-time trigger)
  • Cleans up the observer on unmount
  • Uses appropriate default values (200px margin, 0.1 threshold)

The dependency array including isVisible is intentional and safe: when isVisible becomes true, the effect re-runs but returns early, and the previous observer is properly disconnected in cleanup.

frontend/src/components/LazyImage/LazyImage.tsx (1)

30-60: LGTM! LazyImage rendering logic is well-structured.

The component correctly:

  • Shows a skeleton placeholder while the image loads
  • Fades in the image smoothly once loaded (opacity transition)
  • Handles errors with a clear message
  • Uses absolute positioning to overlay states without layout shift

The combination of isVisible, isLoaded, and hasError states ensures proper visual feedback at each stage.

@github-actions
Copy link
Contributor

github-actions bot commented Jan 8, 2026

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

2 similar comments
@github-actions
Copy link
Contributor

github-actions bot commented Jan 8, 2026

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

@github-actions
Copy link
Contributor

github-actions bot commented Jan 8, 2026

⚠️ No issue was linked in the PR description.
Please make sure to link an issue (e.g., 'Fixes #issue_number')

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @frontend/src/components/Media/MediaThumbnails.tsx:
- Around line 120-124: The src fallback is ineffective because
convertFileSrc(image.thumbnailPath) always returns a truthy string; update the
LazyImage src to compute the fallback before conversion by passing thumbnailPath
|| '/placeholder.svg' into convertFileSrc (i.e., call convertFileSrc with the
fallback applied to image.thumbnailPath) so the placeholder is used when
thumbnailPath is empty, keeping the change localized to the LazyImage prop where
convertFileSrc and image.thumbnailPath are referenced; verify this aligns with
LazyImage's new error UX that shows "Failed to load image" on errors.
🧹 Nitpick comments (1)
frontend/src/components/Media/ImageCard.tsx (1)

62-68: Simplify alt text extraction and remove empty lines.

The dynamic alt text is a great accessibility improvement over the static 'Sample Title'. However, the logic can be simplified, and the trailing empty lines (67-68) should be removed.

♻️ Simplified implementation
-            alt={
-  image.path
-    ? image.path.split('/').pop() ?? 'Image thumbnail'
-    : 'Image thumbnail'
-}
-
-
+            alt={image.path?.split('/').pop() ?? 'Image thumbnail'}
             className={cn(
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a992596 and 9300088.

📒 Files selected for processing (3)
  • frontend/src/components/LazyImage/LazyImage.tsx
  • frontend/src/components/Media/ImageCard.tsx
  • frontend/src/components/Media/MediaThumbnails.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src/components/LazyImage/LazyImage.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
frontend/src/components/Media/ImageCard.tsx (1)
frontend/src/components/LazyImage/LazyImage.tsx (1)
  • LazyImage (12-60)
frontend/src/components/Media/MediaThumbnails.tsx (1)
frontend/src/components/LazyImage/LazyImage.tsx (1)
  • LazyImage (12-60)
🔇 Additional comments (3)
frontend/src/components/Media/MediaThumbnails.tsx (1)

3-3: LGTM! Clean import of LazyImage component.

The relative import path is correct and follows the project structure.

frontend/src/components/Media/ImageCard.tsx (2)

10-10: LGTM! Consistent import pattern.

The LazyImage import follows the same pattern as MediaThumbnails and is correctly placed.


58-73: Verify convertFileSrc fallback behavior.

The LazyImage integration preserves the existing hover effects and styling correctly. However, similar to MediaThumbnails, verify the fallback logic:

src={convertFileSrc(
  image.thumbnailPath || image.path || '/placeholder.svg',
)}

The three-level fallback looks robust, but if thumbnailPath or path contain empty strings (rather than null/undefined), those empty strings will be passed to convertFileSrc. Confirm that convertFileSrc handles empty strings appropriately and returns a falsy value that triggers the fallback chain.

Also note that the error handling UX has changed: instead of showing a placeholder image on load errors, LazyImage now displays "Failed to load image" text.

The verification script from the MediaThumbnails review will help confirm convertFileSrc behavior patterns across the codebase.

Comment on lines +120 to +124
<LazyImage
src={convertFileSrc(image.thumbnailPath) || '/placeholder.svg'}
alt={`thumbnail-${index}`}
className="h-full w-full object-cover"
/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find convertFileSrc definition
echo "=== Searching for convertFileSrc definition ==="
rg -n "export.*convertFileSrc|function convertFileSrc" --type=ts --type=tsx

# Search in common locations
echo -e "\n=== Checking common utility files ==="
fd "convertFileSrc|file.*src" -e ts -e tsx | head -20

# Search for imports of convertFileSrc
echo -e "\n=== Searching for convertFileSrc imports ==="
rg "import.*convertFileSrc" --type=ts --type=tsx | head -20

Repository: AOSSIE-Org/PictoPy

Length of output: 267


🏁 Script executed:

#!/bin/bash
# Adjust search - don't use tsx file type
echo "=== Searching for convertFileSrc definition ==="
rg -n "export.*convertFileSrc|function convertFileSrc" 

echo -e "\n=== Find all .tsx and .ts files containing convertFileSrc ==="
rg -l "convertFileSrc" | head -20

echo -e "\n=== Check MediaThumbnails.tsx imports ==="
head -30 frontend/src/components/Media/MediaThumbnails.tsx

echo -e "\n=== Search for convertFileSrc usage patterns ==="
rg -B2 -A2 "convertFileSrc" | head -50

Repository: AOSSIE-Org/PictoPy

Length of output: 4836


🏁 Script executed:

#!/bin/bash
# Check LazyImage component
echo "=== LazyImage component implementation ==="
find . -name "LazyImage*" -type f
cat ./frontend/src/components/LazyImage/LazyImage.tsx

echo -e "\n=== Check ImageCard pattern more closely ==="
rg -B5 -A5 "thumbnailPath || image.path" frontend/src/components/Media/ImageCard.tsx

echo -e "\n=== Check if there are any comments about convertFileSrc behavior ==="
rg -B3 -A3 "convertFileSrc" frontend/src/hooks/UseVideos.ts

Repository: AOSSIE-Org/PictoPy

Length of output: 5878


🏁 Script executed:

#!/bin/bash
# Search for any test files that might show convertFileSrc behavior
echo "=== Search for convertFileSrc tests or edge cases ==="
find . -name "*.test.*" -o -name "*.spec.*" | xargs grep -l "convertFileSrc" 2>/dev/null

echo -e "\n=== Check if MediaThumbnails or related files have any error handling ==="
rg -B10 -A10 "convertFileSrc.*placeholder" frontend/src/components/Media/MediaThumbnails.tsx

echo -e "\n=== Search for any validation of paths before convertFileSrc ==="
rg -B5 "convertFileSrc" frontend/src/components/Media/MediaThumbnails.tsx | head -40

echo -e "\n=== Check if there's any conditional logic around thumbnailPath ==="
rg "thumbnailPath" frontend/src/components/Media/MediaThumbnails.tsx -B2 -A2

Repository: AOSSIE-Org/PictoPy

Length of output: 1636


🌐 Web query:

Tauri convertFileSrc behavior empty string null invalid path return value

💡 Result:

Short answer

  • convertFileSrc simply encodes the given string path and returns a protocol URL (e.g. asset://<url‑encoded‑path> on Unix, http(s)://asset.localhost/<url‑encoded‑path> on Windows). It does not check that the file exists. [1][2]
  • Because it only transforms the input string, calling it on an already‑converted URL will double‑encode/ nest the protocol (e.g. asset://asset%3A%2F%2F...); this is a known source of bugs. [3]
  • The API always returns a string URL for the supplied input; invalid filesystem paths produce a valid (but pointing‑nowhere) asset URL rather than an error. The function expects a string input — passing non‑strings (null/undefined) is a type mismatch in JS/TS and can produce unexpected results (coercion to "null"/"undefined" or a runtime error depending on usage). [1][2][3]

Sources

  • convertFileSrc doc in Tauri API (description, params, return type). [1]
  • Rust/wasm wrappers and tauri_sys docs showing convert_file_src semantics (returns Url; no existence check). [2]
  • GitHub issue describing double‑conversion problem and examples. [3]

References
[1] Tauri convertFileSrc (API docs).
[2] tauri convert_file_src (tauri_sys / tauri-wasm docs).
[3] tauri-apps/tauri issue: "Avoid calling convertFileSrc twice" (discussing double‑encoding).


Simplify the fallback logic for consistency and clarity.

The fallback pattern convertFileSrc(image.thumbnailPath) || '/placeholder.svg' is ineffective because convertFileSrc always returns a truthy string value (a protocol URL like asset://), even for invalid or empty paths. The || '/placeholder.svg' branch will never execute.

For consistency with the pattern used elsewhere in the codebase (e.g., ImageCard), place the fallback before converting:

src={convertFileSrc(image.thumbnailPath || '/placeholder.svg')}

This ensures that if thumbnailPath is empty or invalid, the placeholder is used before conversion.

Note: The error handling UX has changed—LazyImage now displays "Failed to load image" text on load errors rather than swapping to the placeholder image. Ensure this aligns with your design requirements.

🤖 Prompt for AI Agents
In @frontend/src/components/Media/MediaThumbnails.tsx around lines 120 - 124,
The src fallback is ineffective because convertFileSrc(image.thumbnailPath)
always returns a truthy string; update the LazyImage src to compute the fallback
before conversion by passing thumbnailPath || '/placeholder.svg' into
convertFileSrc (i.e., call convertFileSrc with the fallback applied to
image.thumbnailPath) so the placeholder is used when thumbnailPath is empty,
keeping the change localized to the LazyImage prop where convertFileSrc and
image.thumbnailPath are referenced; verify this aligns with LazyImage's new
error UX that shows "Failed to load image" on errors.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant