feat(react): add useSuspenseImage, <SuspenseImage/>#1774
feat(react): add useSuspenseImage, <SuspenseImage/>#1774
useSuspenseImage, <SuspenseImage/>#1774Conversation
|
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 🚧
| 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.
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.
I agree. Let's start by implementing Image first and consider gradual expansion.
There was a problem hiding this comment.
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.
I worry that this shouldn't work in react-native.
There was a problem hiding this comment.
I'm looking for an excellent way to make this work in an RN environment without using the Web DOM API. I want to abstract images solely with Promises and improve it so that it isn't affected by the platform.
There was a problem hiding this comment.
KOR
new Image()는 단순한 이미지 로더가 아니라, 브라우저 렌더링 파이프라인(네트워크, 캐시, 디코딩)에 이미지를 명시적으로 등록하는 Web Platform API입니다.
이 방식을 사용하면 Suspense가 해제되는 시점이 <img src="...">가 실제로 paint 가능한 시점과 최대한 일치하게 됩니다.
즉, “Suspense가 풀린 시점 = 브라우저에서 이미지가 준비된 시점”이라는 의미적 보장을 할 수 있습니다. 이 보장은 { src } 객체나 fetch 기반 접근으로는 얻기 어렵습니다.
(개인적으로는 new Image를 모킹한 객체를 만들어 해결하는 방식은 의미적으로 맞지 않다고 봅니다.
new Image()는 단순 객체 생성이 아니라, 브라우저 렌더링 파이프라인과 직접 상호작용하는 강력한 API이기 때문입니다.)
ref:
- https://html.spec.whatwg.org/multipage/images.html#decoding-images
- https://html.spec.whatwg.org/multipage/images.html#updating-the-image-data
문제는 new Image()가 Web 전용 API라는 점입니다. React Native에는 DOM이나 HTMLImageElement 개념이 없기 때문에, Web과 RN에서 동일한 구현을 공유하는 것은 구조적으로 불가능합니다. RN에서는 의미적으로 대응되는 수단이 Image.prefetch 정도입니다.
그래서 모든 플랫폼을 지원하려면 현실적인 선택지는 두 가지로 보입니다.
- 라이브러리가 Web / RN 구현을 모두 제공하는 방식
├─ ImageView.web.tsx
├─ ImageView.native.tsx
- Web:
new Image() - RN:
Image.prefetch .web.tsx/.native.tsx로 분리- 장점: 사용자 설정 없이 바로 사용 가능, Suspense 해제 의미를 라이브러리가 일관되게 보장
- 단점: RN 구현 책임 +
react-nativepeerDependency 필요
(RN 쪽 구현을 별도 유틸/패키지로 분리하는 선택지도 가능)
- 라이브러리는 추상 API만 제공하고, 구현은 사용자에게 위임하는 방식
// core
export interface ImageLoader {
preload(src: string): Promise<void>;
}- 라이브러리는 Promise 기반 preload 인터페이스만 정의
- Web / RN preload 구현은 사용자가 직접 제공
- 장점: 완전히 플랫폼 불가지론, 의존성 최소화
- 단점: 사용자 설정 부담, Suspense 해제 시점의 의미가 구현에 따라 달라질 수 있음
RN 지원을 하려면 라이브러리가 플랫폼별 구현을 책임질지, 아니면 구현을 사용자에게 위임할지를 고민해야할 거 같습니다.
new Image() is not just a simple image loader, but a Web Platform API that explicitly registers the image with the browser's rendering pipeline (network, cache, decoding).
Using this method allows the point at which Suspense is lifted to closely match the point when <img src="..."> is actually paintable.
In other words, you can semantically guarantee that “Suspense resolution = image is ready in the browser.”
This guarantee is hard to achieve using { src } objects or fetch-based approaches.
(Personally, I don't think mocking a new Image object is semantically correct.
new Image() is not just about creating an object; it's a powerful API that directly interacts with the browser’s rendering pipeline.)
ref:
- https://html.spec.whatwg.org/multipage/images.html#decoding-images
- https://html.spec.whatwg.org/multipage/images.html#updating-the-image-data
The problem is that new Image() is a web-only API. React Native doesn’t have a DOM or the concept of HTMLImageElement, so it’s structurally impossible to share the same implementation between Web and RN.
The closest semantic equivalent in RN is Image.prefetch.
So to support all platforms, there seem to be two realistic choices:
- The library provides both Web / RN implementations
├─ ImageView.web.tsx
├─ ImageView.native.tsx
- Web:
new Image() - RN:
Image.prefetch - Separate with
.web.tsx/.native.tsx - Pros: Works out of the box with consistent semantics around Suspense resolution
- Cons: Library must own RN implementation + needs
react-nativeas a peerDependency
(You could also extract the RN implementation into a separate utility or package)
- The library provides only an abstract API, and implementation is delegated to the user
// core
export interface ImageLoader {
preload(src: string): Promise<void>;
}- The library defines a preload interface based on Promise
- Web / RN implementation is provided by the user
- Pros: Platform-agnostic, minimal dependencies
- Cons: Users bear the setup burden, and the meaning of Suspense resolution depends on user implementation
If you want to support RN, the key question is whether the library should own the platform-specific implementations or delegate them to the user.
There was a problem hiding this comment.
Because we have @suspensive/react-dom, I think this interface could be added in it, if we don't remove @suspensive/react-dom. but recently, I wanted to drop @suspensive/react-dom because:
- Suspensive
<InView/>is not better than react-intersection-observer, When I first make<InView/>of Suspensive, I wanna support isomorphic InView forreact-domandreact-nativeboth supported. but I couldn't do it well <FadeIn/>could be better to give implementation to library user itself
There was a problem hiding this comment.
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 | ||
| } |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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}`)) |
There was a problem hiding this comment.
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) |
| img.src = src | ||
|
|
||
| // if the image is already loaded in the browser | ||
| if (img.complete && img.naturalWidth > 0) { |
There was a problem hiding this comment.
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) { |
|
@gwansikk After we discussion in call, we close this PR |
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