-
Notifications
You must be signed in to change notification settings - Fork 82
feat(react): add useSuspenseImage, <SuspenseImage/>
#1774
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
People can be co-author:
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
<AwaitImage/> for suspense for image load with suspense 🚧<AwaitImage/> for suspense for image load with suspense 🚧
packages/react/src/SuspenseImage.tsx
Outdated
| function preloadImage(src: string): Promise<HTMLImageElement> { | ||
| const cached = imageCache.get(src) | ||
| if (cached) return cached | ||
|
|
||
| const imageLoadPromise = new Promise<HTMLImageElement>((resolve, reject) => { | ||
| const img = new Image() | ||
| img.onload = () => resolve(img) | ||
| img.onerror = () => { | ||
| imageCache.delete(src) | ||
| reject(new Error(`Failed to load image: ${src}`)) | ||
| } | ||
| img.src = src | ||
| }) | ||
|
|
||
| imageCache.set(src, imageLoadPromise) | ||
| return imageLoadPromise | ||
| } | ||
|
|
||
| export function AwaitImage({ src, children }: { src: string; children: (img: HTMLImageElement) => ReactNode }) { | ||
| const img = use(preloadImage(src)) | ||
| return <>{children(img)}</> | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At first, I thought AwaitImage would be helpful.
But ultimately, I thought:
- If I wanted to support images,
- I also wanted to support videos,
- I also wanted to support 3D assets.
- etc...
I wish there was an interface that could load blobs that could handle all img, video, and 3D assets. It just loads blobs, not only img.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree. Let's start by implementing Image first and consider gradual expansion.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree that we need to support not only images but also other resources like videos and 3D assets,, etc.
I'd like to complete the current SuspenseImage implementation first, then explore this direction in a follow-up.
I'm still considering the API design approach: whether to create a single generic api that handles all resource types, or provide purpose-specific APIs for each resource type.
|
Size Change: +81 B (+0.09%) Total Size: 91.7 kB
ℹ️ View Unchanged
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #1774 +/- ##
==========================================
- Coverage 93.57% 93.53% -0.04%
==========================================
Files 45 47 +2
Lines 731 773 +42
Branches 189 196 +7
==========================================
+ Hits 684 723 +39
- Misses 41 44 +3
Partials 6 6
🚀 New features to boost your workflow:
|
|
Sorry for the delay in getting things done. I'm back now. In the new year, I’ll be able to dedicate more time to Suspensive! |
| } | ||
|
|
||
| const imageLoadPromise = new Promise<HTMLImageElement>((resolve, reject) => { | ||
| const img = new Image() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I worry that this shouldn't work in react-native.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces suspense-based image loading capabilities by adding useSuspenseImage hook and <SuspenseImage/> component to the @suspensive/react package. These utilities enable declarative image loading with React Suspense, automatically suspending rendering until images are loaded while caching results to prevent redundant network requests.
Key changes:
- Implements a custom
use()utility to support the Suspense pattern for React 18 - Adds image loading with built-in caching by src URL to avoid duplicate requests
- Provides SSR-friendly behavior that returns mock objects server-side to ensure img tags in initial HTML
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/react/src/utils/use.ts | Implements internal use() utility for Suspense pattern with promise state tracking |
| packages/react/src/utils/use.spec.ts | Comprehensive test coverage for the use() utility across all promise states |
| packages/react/src/SuspenseImage.tsx | Core implementation of useSuspenseImage hook and <SuspenseImage/> component with caching |
| packages/react/src/SuspenseImage.spec.tsx | Test suite for image loading, caching, error handling, and SSR behavior |
| packages/react/src/index.ts | Exports new SuspenseImage component, hook, and types |
| docs/suspensive.org/src/content/en/docs/react/_meta.tsx | Adds SuspenseImage to English documentation navigation |
| docs/suspensive.org/src/content/en/docs/react/SuspenseImage.mdx | English documentation with examples and API reference |
| docs/suspensive.org/src/content/ko/docs/react/_meta.tsx | Adds SuspenseImage to Korean documentation navigation |
| docs/suspensive.org/src/content/ko/docs/react/SuspenseImage.mdx | Korean documentation with examples and API reference |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (img.complete && img.naturalWidth > 0) { | ||
| resolve(img) | ||
| return | ||
| } |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the image is already complete (cached in browser), the promise resolves immediately but the onload and onerror handlers are still attached. While this isn't harmful since they won't be called, it's cleaner to avoid setting handlers that will never be used. Consider returning early after resolving to avoid the unnecessary handler assignments on lines 67-70.
| function preloadImage(src: string): Promise<HTMLImageElement> { | ||
| const cached = imageCache.get(src) | ||
| if (cached) { | ||
| return cached | ||
| } | ||
|
|
||
| const imageLoadPromise = new Promise<HTMLImageElement>((resolve, reject) => { | ||
| const img = new Image() | ||
| img.src = src | ||
|
|
||
| // if the image is already loaded in the browser | ||
| if (img.complete && img.naturalWidth > 0) { | ||
| resolve(img) | ||
| return | ||
| } | ||
|
|
||
| img.onload = () => resolve(img) | ||
| img.onerror = () => { | ||
| reject(new Error(`Failed to load image: ${src}`)) | ||
| } | ||
| }) | ||
|
|
||
| imageCache.set(src, imageLoadPromise) | ||
| return imageLoadPromise |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The image loading logic doesn't handle cleanup when an image fails to load. If an image fails to load, the rejected promise remains in the cache indefinitely. This means subsequent attempts to load the same failed image will continue to use the cached rejection. Consider either removing failed entries from the cache to allow retries, or documenting this behavior as intentional for performance reasons.
| img.onerror = () => { | ||
| reject(new Error(`Failed to load image: ${src}`)) |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The error message doesn't provide context about why the image failed to load. Consider including additional information such as HTTP status codes or network errors if available. However, since the Image API doesn't provide detailed error information in the onerror callback, you could clarify in the error message that the failure could be due to network issues, CORS problems, or an invalid URL.
| img.onerror = () => { | |
| reject(new Error(`Failed to load image: ${src}`)) | |
| img.onerror = (event) => { | |
| const error = new Error( | |
| `Failed to load image: ${src}. This may be due to network connectivity issues, CORS restrictions, or an invalid or inaccessible URL.`, | |
| ) | |
| ;(error as any).event = event | |
| reject(error) |
|
|
||
| - Uses an internal cache to prevent reloading the same image multiple times. | ||
| - Triggers Suspense to suspend rendering until the image is loaded. | ||
| - In SSR environments, returns a server-rendered `<img>` tag. |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation states that the hook "returns a server-rendered <img> tag" in SSR, but this is inaccurate. The hook returns a mock HTMLImageElement object, not a React element or tag. The wording should be corrected to say it "returns a mock HTMLImageElement object" or "ensures the <img> tag can be rendered".
| - In SSR environments, returns a server-rendered `<img>` tag. | |
| - In SSR environments, returns a mock `HTMLImageElement` object to ensure the `<img>` tag can be rendered. |
| ```tsx /SuspenseImage/ | ||
| import { SuspenseImage, Suspense } from '@suspensive/react' | ||
|
|
||
| function App() { | ||
| return ( | ||
| <ErrorBoundary fallback={<div>Failed to load image</div>}> | ||
| <Suspense fallback={<div>Loading image...</div>}> | ||
| <SuspenseImage src="https://example.com/image.jpg"> | ||
| {/** Guarantees the image is loaded, allowing declarative access to the element */} | ||
| {(img) => <img src={img.src} alt="Example" />} | ||
| </SuspenseImage> | ||
| </Suspense> | ||
| </ErrorBoundary> | ||
| ) | ||
| } |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation example shows importing ErrorBoundary from @suspensive/react, but ErrorBoundary is not imported in the code snippet. This will cause a runtime error. Either add the import statement or remove the ErrorBoundary from the example.
| ```tsx /useSuspenseImage/ | ||
| import { useSuspenseImage, Suspense } from '@suspensive/react' | ||
|
|
||
| function ImageComponent() { | ||
| // Suspense를 발생시켜 이미지가 로드될 때까지 컴포넌트 렌더를 중단합니다. | ||
| const img = useSuspenseImage('https://example.com/image.jpg') | ||
|
|
||
| return <img src={img.src} alt="Example" /> | ||
| } | ||
|
|
||
| function App() { | ||
| return ( | ||
| <ErrorBoundary fallback={<div>이미지를 불러올 수 없습니다</div>}> | ||
| <Suspense fallback={<div>이미지 로딩 중...</div>}> | ||
| <ImageComponent /> | ||
| </Suspense> | ||
| </ErrorBoundary> | ||
| ) | ||
| } |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation example shows using ErrorBoundary, but ErrorBoundary is not imported in the code snippet. This will cause a runtime error. Either add the import statement or remove the ErrorBoundary from the example.
| img.src = src | ||
|
|
||
| // if the image is already loaded in the browser | ||
| if (img.complete && img.naturalWidth > 0) { |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The check img.naturalWidth > 0 may not be reliable for all images. Some valid images can have a naturalWidth of 0 before they're fully decoded. Additionally, the condition should include checking img.naturalHeight > 0 for consistency, or consider checking both dimensions or neither. A safer approach is to only rely on img.complete and verify proper loading through the onload/onerror handlers.
| if (img.complete && img.naturalWidth > 0) { | |
| if (img.complete) { |
| * In SSR, return a mock object immediately instead of suspending. | ||
| * This ensures the <img> tag is included in the server-rendered HTML for SEO, | ||
| */ | ||
| return { src, complete: false } as HTMLImageElement |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The mock object returned for SSR only includes src and complete properties, but the actual HTMLImageElement has many other properties that consuming code might access (e.g., naturalWidth, naturalHeight, width, height, alt). While this minimal mock may work for basic use cases, accessing other properties will return undefined in SSR, which could lead to runtime errors or unexpected behavior. Consider either documenting which properties are safe to access in SSR, or providing a more complete mock object with default values for commonly used properties.
|
|
||
| - 내부 캐시를 사용하여 동일한 이미지를 여러 번 다시 로드하는 것을 방지합니다. | ||
| - Suspense를 발생시켜 이미지가 로드될 때까지 렌더링을 중단합니다. | ||
| - SSR 환경에서는 서버 렌더링된 `<img>` 태그를 반환합니다. |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Korean documentation states that the hook "returns a server-rendered <img> tag" (서버 렌더링된 <img> 태그를 반환합니다), but this is inaccurate. The hook returns a mock HTMLImageElement object, not a React element or tag. The wording should be corrected to accurately describe what is returned.
| - SSR 환경에서는 서버 렌더링된 `<img>` 태그를 반환합니다. | |
| - SSR 환경에서는 모의 `HTMLImageElement` 객체를 반환합니다. |
| ```tsx /SuspenseImage/ | ||
| import { SuspenseImage, Suspense } from '@suspensive/react' | ||
|
|
||
| function App() { | ||
| return ( | ||
| <ErrorBoundary fallback={<div>이미지를 불러올 수 없습니다</div>}> | ||
| <Suspense fallback={<div>이미지 로딩 중...</div>}> | ||
| <SuspenseImage src="https://example.com/image.jpg"> | ||
| {/** 이미지 로드가 성공된 상태를 보장하여 선언적으로 요소에 접근할 수 있습니다 */} | ||
| {(img) => <img src={img.src} alt="Example" />} | ||
| </SuspenseImage> | ||
| </Suspense> | ||
| </ErrorBoundary> | ||
| ) | ||
| } |
Copilot
AI
Jan 7, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation example shows importing ErrorBoundary from @suspensive/react, but ErrorBoundary is not imported in the code snippet. This will cause a runtime error. Either add the import statement or remove the ErrorBoundary from the example.
Overview
closed: #1444
Summary
This PR introduces a
useSuspenseImagehook and a<SuspenseImage />component to the React package. They enable image loading through React Suspense, suspending rendering while an image is loading and resuming once it is ready. To prevent redundant work, image loading is cached bysrc, and for SSR environments, a mock image object is returned immediately so that<img>tags are included in the server-rendered HTML.Key Point
Suspense-based image loading
Image loading throws a Promise during the pending state, allowing Suspense boundaries to control fallback rendering. Once resolved, an
HTMLImageElementis returned.Built-in caching
A
Map<string, Promise<HTMLImageElement>>caches image-loading Promises bysrc, avoiding duplicate network requests and repeated instantiation.SSR-friendly behavior
On the server, the hook does not suspend. Instead, it immediately returns a mock object shaped like
HTMLImageElement(e.g.{ src, complete: false }), ensuring<img>elements appear in the initial HTML output for SEO and consistency.Internal
use()utilityAn internal helper attaches
status / value / reasonto Promises and either throws or returns based on their state, implementing a canonical Suspense pattern. (support react 18)Documentation added
Both English and Korean documentation (
SuspenseImage.mdx) and navigation metadata are included.Example
Result
<Suspense>boundary is sufficient.<img>elements, preventing missing images in initial HTML and improving SEO and perceived performance.PR Checklist