Skip to content

Conversation

@gwansikk
Copy link
Collaborator

@gwansikk gwansikk commented Oct 10, 2025

Overview

closed: #1444

Summary

This PR introduces a useSuspenseImage hook 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 by src, 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 HTMLImageElement is returned.

  • Built-in caching
    A Map<string, Promise<HTMLImageElement>> caches image-loading Promises by src, 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() utility
    An internal helper attaches status / value / reason to 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

import { Suspense, SuspenseImage, useSuspenseImage } from '@suspensive/react';

function WithComponent() {
  return (
    <Suspense fallback={<div>Loading image...</div>}>
      <SuspenseImage src="https://picsum.photos/400/300">
        {(img) => <img src={img.src} alt="example" />}
      </SuspenseImage>
    </Suspense>
  );
}

function WithHook() {
  const img = useSuspenseImage('https://picsum.photos/400/300');
  return <img src={img.src} alt="example" />;
}

Result

  • Consumers no longer need to manage explicit loading state for images; a single <Suspense> boundary is sufficient.
  • Users can manage images declaratively. They can access elements with the guarantee that the image has been loaded.
  • Duplicate image loads are avoided through caching, reducing unnecessary network and processing overhead.
  • SSR output consistently includes <img> elements, preventing missing images in initial HTML and improving SEO and perceived performance.

PR Checklist

  • I did below actions if need
  1. I read the Contributing Guide
  2. I added documents and tests.

@gwansikk gwansikk self-assigned this Oct 10, 2025
@gwansikk gwansikk linked an issue Oct 10, 2025 that may be closed by this pull request
@changeset-bot
Copy link

changeset-bot bot commented Oct 10, 2025

⚠️ No Changeset found

Latest commit: 6ca0ccd

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coauthors
Copy link

coauthors bot commented Oct 10, 2025

People can be co-author:

Candidate Reasons Count Add this as commit message
@gwansikk #1774 (comment) #1774 (comment) #1774 (comment) #1774 (review) #1774 (review) #1774 #1774 (review) #1774 (comment) 8 Co-authored-by: gwansikk <[email protected]>
@manudeli #1774 (comment) #1774 (comment) #1774 (comment) #1774 (review) #1774 (review) #1774 (review) 6 Co-authored-by: manudeli <[email protected]>
@codecov-commenter #1774 (comment) 1 Co-authored-by: codecov-commenter <[email protected]>

@vercel
Copy link

vercel bot commented Oct 10, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
suspensive-next-streaming-react-query Ready Ready Preview, Comment Jan 7, 2026 6:24am
v2.suspensive.org Error Error Jan 7, 2026 6:24am
v3.suspensive.org Ready Ready Preview, Comment Jan 7, 2026 6:24am
visualization.suspensive.org Ready Ready Preview, Comment Jan 7, 2026 6:24am

@gwansikk gwansikk changed the title 🚧 feat(web): add <AwaitImage/> for suspense for image load with suspense 🚧 🚧 feat: add <AwaitImage/> for suspense for image load with suspense 🚧 Oct 10, 2025
Comment on lines 6 to 27
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)}</>
}
Copy link
Member

@manudeli manudeli Oct 11, 2025

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:

  1. If I wanted to support images,
  2. I also wanted to support videos,
  3. I also wanted to support 3D assets.
  4. 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.

Copy link
Collaborator Author

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.

Copy link
Collaborator Author

@gwansikk gwansikk Dec 16, 2025

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.

@github-actions
Copy link
Contributor

github-actions bot commented Oct 24, 2025

Size Change: +81 B (+0.09%)

Total Size: 91.7 kB

Filename Size Change
packages/react/dist/DefaultProps-********.mjs 937 B +5 B (+0.54%)
packages/react/dist/Delay-********.mjs 1.04 kB +6 B (+0.58%)
packages/react/dist/ErrorBoundary-********.mjs 2.1 kB +5 B (+0.24%)
packages/react/dist/index.cjs 397 B +32 B (+8.77%) 🔍
packages/react/dist/index.mjs 360 B +31 B (+9.42%) 🔍
packages/react/dist/lazy-********.mjs 2 kB +4 B (+0.2%)
packages/react/dist/lazy.mjs 128 B +1 B (+0.79%)
packages/react/dist/noop-********.mjs 142 B -2 B (-1.39%)
packages/react/dist/useIsClient-********.mjs 250 B -1 B (-0.4%)
ℹ️ View Unchanged
Filename Size
packages/jotai/dist/Atom-********.cjs 328 B
packages/jotai/dist/Atom-********.mjs 263 B
packages/jotai/dist/Atom.cjs 93 B
packages/jotai/dist/Atom.mjs 87 B
packages/jotai/dist/AtomValue-********.cjs 315 B
packages/jotai/dist/AtomValue-********.mjs 247 B
packages/jotai/dist/AtomValue.cjs 99 B
packages/jotai/dist/AtomValue.mjs 93 B
packages/jotai/dist/index.cjs 150 B
packages/jotai/dist/index.mjs 133 B
packages/jotai/dist/SetAtom-********.cjs 313 B
packages/jotai/dist/SetAtom-********.mjs 246 B
packages/jotai/dist/SetAtom.cjs 97 B
packages/jotai/dist/SetAtom.mjs 91 B
packages/next/dist/index.cjs 256 B
packages/next/dist/index.mjs 250 B
packages/next/dist/react-******.cjs 217 B
packages/next/dist/react-******.mjs 213 B
packages/react-dom/dist/FadeIn-********.cjs 471 B
packages/react-dom/dist/FadeIn-********.mjs 402 B
packages/react-dom/dist/FadeIn.cjs 96 B
packages/react-dom/dist/FadeIn.mjs 90 B
packages/react-dom/dist/index.cjs 176 B
packages/react-dom/dist/index.mjs 153 B
packages/react-dom/dist/InView-********.cjs 733 B
packages/react-dom/dist/InView-********.mjs 670 B
packages/react-dom/dist/InView.cjs 96 B
packages/react-dom/dist/InView.mjs 90 B
packages/react-dom/dist/useFadeIn-********.cjs 496 B
packages/react-dom/dist/useFadeIn-********.mjs 432 B
packages/react-dom/dist/useFadeIn.cjs 99 B
packages/react-dom/dist/useFadeIn.mjs 93 B
packages/react-dom/dist/useInView-********.cjs 1.66 kB
packages/react-dom/dist/useInView-********.mjs 1.61 kB
packages/react-dom/dist/useInView.cjs 99 B
packages/react-dom/dist/useInView.mjs 93 B
packages/react-query-4/dist/ClientOnly-********.cjs 394 B
packages/react-query-4/dist/ClientOnly-********.mjs 323 B
packages/react-query-4/dist/createGetQueryClient-********.cjs 1.08 kB
packages/react-query-4/dist/createGetQueryClient-********.mjs 1 kB
packages/react-query-4/dist/createGetQueryClient.cjs 98 B
packages/react-query-4/dist/createGetQueryClient.mjs 91 B
packages/react-query-4/dist/index.cjs 542 B
packages/react-query-4/dist/index.mjs 450 B
packages/react-query-4/dist/infiniteQueryOptions-********.cjs 375 B
packages/react-query-4/dist/infiniteQueryOptions-********.mjs 302 B
packages/react-query-4/dist/infiniteQueryOptions.cjs 98 B
packages/react-query-4/dist/infiniteQueryOptions.mjs 91 B
packages/react-query-4/dist/IsFetching-********.cjs 347 B
packages/react-query-4/dist/IsFetching-********.mjs 266 B
packages/react-query-4/dist/IsFetching.cjs 102 B
packages/react-query-4/dist/IsFetching.mjs 96 B
packages/react-query-4/dist/Mutation-********.cjs 393 B
packages/react-query-4/dist/Mutation-********.mjs 315 B
packages/react-query-4/dist/Mutation.cjs 100 B
packages/react-query-4/dist/Mutation.mjs 94 B
packages/react-query-4/dist/mutationOptions-********.cjs 203 B
packages/react-query-4/dist/mutationOptions-********.mjs 147 B
packages/react-query-4/dist/mutationOptions.cjs 90 B
packages/react-query-4/dist/mutationOptions.mjs 84 B
packages/react-query-4/dist/objectSpread2-********.cjs 799 B
packages/react-query-4/dist/objectSpread2-********.mjs 767 B
packages/react-query-4/dist/objectWithoutProperties-********.cjs 406 B
packages/react-query-4/dist/objectWithoutProperties-********.mjs 366 B
packages/react-query-4/dist/PrefetchInfiniteQuery-********.cjs 476 B
packages/react-query-4/dist/PrefetchInfiniteQuery-********.mjs 407 B
packages/react-query-4/dist/PrefetchInfiniteQuery.cjs 114 B
packages/react-query-4/dist/PrefetchInfiniteQuery.mjs 107 B
packages/react-query-4/dist/PrefetchQuery-********.cjs 463 B
packages/react-query-4/dist/PrefetchQuery-********.mjs 396 B
packages/react-query-4/dist/PrefetchQuery.cjs 105 B
packages/react-query-4/dist/PrefetchQuery.mjs 99 B
packages/react-query-4/dist/QueriesHydration-********.cjs 1.6 kB
packages/react-query-4/dist/QueriesHydration-********.mjs 1.52 kB
packages/react-query-4/dist/QueriesHydration.cjs 93 B
packages/react-query-4/dist/QueriesHydration.mjs 87 B
packages/react-query-4/dist/QueryClientConsumer-********.cjs 356 B
packages/react-query-4/dist/QueryClientConsumer-********.mjs 285 B
packages/react-query-4/dist/QueryClientConsumer.cjs 109 B
packages/react-query-4/dist/QueryClientConsumer.mjs 102 B
packages/react-query-4/dist/queryOptions-********.cjs 366 B
packages/react-query-4/dist/queryOptions-********.mjs 295 B
packages/react-query-4/dist/queryOptions.cjs 89 B
packages/react-query-4/dist/queryOptions.mjs 83 B
packages/react-query-4/dist/SuspenseInfiniteQuery-********.cjs 651 B
packages/react-query-4/dist/SuspenseInfiniteQuery-********.mjs 566 B
packages/react-query-4/dist/SuspenseInfiniteQuery.cjs 114 B
packages/react-query-4/dist/SuspenseInfiniteQuery.mjs 107 B
packages/react-query-4/dist/SuspenseQueries-********.cjs 572 B
packages/react-query-4/dist/SuspenseQueries-********.mjs 487 B
packages/react-query-4/dist/SuspenseQueries.cjs 107 B
packages/react-query-4/dist/SuspenseQueries.mjs 101 B
packages/react-query-4/dist/SuspenseQuery-********.cjs 638 B
packages/react-query-4/dist/SuspenseQuery-********.mjs 553 B
packages/react-query-4/dist/SuspenseQuery.cjs 105 B
packages/react-query-4/dist/SuspenseQuery.mjs 99 B
packages/react-query-4/dist/usePrefetchInfiniteQuery-********.cjs 464 B
packages/react-query-4/dist/usePrefetchInfiniteQuery-********.mjs 400 B
packages/react-query-4/dist/usePrefetchInfiniteQuery.cjs 117 B
packages/react-query-4/dist/usePrefetchInfiniteQuery.mjs 110 B
packages/react-query-4/dist/usePrefetchQuery-********.cjs 455 B
packages/react-query-4/dist/usePrefetchQuery-********.mjs 392 B
packages/react-query-4/dist/usePrefetchQuery.cjs 108 B
packages/react-query-4/dist/usePrefetchQuery.mjs 102 B
packages/react-query-4/dist/useSuspenseInfiniteQuery-********.cjs 380 B
packages/react-query-4/dist/useSuspenseInfiniteQuery-********.mjs 305 B
packages/react-query-4/dist/useSuspenseInfiniteQuery.cjs 117 B
packages/react-query-4/dist/useSuspenseInfiniteQuery.mjs 110 B
packages/react-query-4/dist/useSuspenseQueries-********.cjs 375 B
packages/react-query-4/dist/useSuspenseQueries-********.mjs 300 B
packages/react-query-4/dist/useSuspenseQueries.cjs 111 B
packages/react-query-4/dist/useSuspenseQueries.mjs 104 B
packages/react-query-4/dist/useSuspenseQuery-********.cjs 369 B
packages/react-query-4/dist/useSuspenseQuery-********.mjs 298 B
packages/react-query-4/dist/useSuspenseQuery.cjs 108 B
packages/react-query-4/dist/useSuspenseQuery.mjs 102 B
packages/react-query-5/dist/ClientOnly-********.cjs 394 B
packages/react-query-5/dist/ClientOnly-********.mjs 323 B
packages/react-query-5/dist/createGetQueryClient-********.cjs 1.08 kB
packages/react-query-5/dist/createGetQueryClient-********.mjs 1.01 kB
packages/react-query-5/dist/createGetQueryClient.cjs 98 B
packages/react-query-5/dist/createGetQueryClient.mjs 91 B
packages/react-query-5/dist/index.cjs 537 B
packages/react-query-5/dist/index.mjs 447 B
packages/react-query-5/dist/infiniteQueryOptions-********.cjs 370 B
packages/react-query-5/dist/infiniteQueryOptions-********.mjs 297 B
packages/react-query-5/dist/infiniteQueryOptions.cjs 98 B
packages/react-query-5/dist/infiniteQueryOptions.mjs 91 B
packages/react-query-5/dist/IsFetching-********.cjs 432 B
packages/react-query-5/dist/IsFetching-********.mjs 351 B
packages/react-query-5/dist/IsFetching.cjs 102 B
packages/react-query-5/dist/IsFetching.mjs 96 B
packages/react-query-5/dist/Mutation-********.cjs 393 B
packages/react-query-5/dist/Mutation-********.mjs 318 B
packages/react-query-5/dist/Mutation.cjs 100 B
packages/react-query-5/dist/Mutation.mjs 94 B
packages/react-query-5/dist/mutationOptions-********.cjs 368 B
packages/react-query-5/dist/mutationOptions-********.mjs 296 B
packages/react-query-5/dist/mutationOptions.cjs 90 B
packages/react-query-5/dist/mutationOptions.mjs 84 B
packages/react-query-5/dist/objectSpread2-********.cjs 799 B
packages/react-query-5/dist/objectSpread2-********.mjs 767 B
packages/react-query-5/dist/objectWithoutProperties-********.cjs 406 B
packages/react-query-5/dist/objectWithoutProperties-********.mjs 366 B
packages/react-query-5/dist/PrefetchInfiniteQuery-********.cjs 469 B
packages/react-query-5/dist/PrefetchInfiniteQuery-********.mjs 396 B
packages/react-query-5/dist/PrefetchInfiniteQuery.cjs 114 B
packages/react-query-5/dist/PrefetchInfiniteQuery.mjs 107 B
packages/react-query-5/dist/PrefetchQuery-********.cjs 462 B
packages/react-query-5/dist/PrefetchQuery-********.mjs 390 B
packages/react-query-5/dist/PrefetchQuery.cjs 105 B
packages/react-query-5/dist/PrefetchQuery.mjs 99 B
packages/react-query-5/dist/QueriesHydration-********.cjs 1.61 kB
packages/react-query-5/dist/QueriesHydration-********.mjs 1.53 kB
packages/react-query-5/dist/QueriesHydration.cjs 93 B
packages/react-query-5/dist/QueriesHydration.mjs 87 B
packages/react-query-5/dist/QueryClientConsumer-********.cjs 358 B
packages/react-query-5/dist/QueryClientConsumer-********.mjs 281 B
packages/react-query-5/dist/QueryClientConsumer.cjs 109 B
packages/react-query-5/dist/QueryClientConsumer.mjs 102 B
packages/react-query-5/dist/queryOptions-********.cjs 361 B
packages/react-query-5/dist/queryOptions-********.mjs 290 B
packages/react-query-5/dist/queryOptions.cjs 89 B
packages/react-query-5/dist/queryOptions.mjs 83 B
packages/react-query-5/dist/SuspenseInfiniteQuery-********.cjs 654 B
packages/react-query-5/dist/SuspenseInfiniteQuery-********.mjs 566 B
packages/react-query-5/dist/SuspenseInfiniteQuery.cjs 114 B
packages/react-query-5/dist/SuspenseInfiniteQuery.mjs 107 B
packages/react-query-5/dist/SuspenseQueries-********.cjs 588 B
packages/react-query-5/dist/SuspenseQueries-********.mjs 505 B
packages/react-query-5/dist/SuspenseQueries.cjs 107 B
packages/react-query-5/dist/SuspenseQueries.mjs 101 B
packages/react-query-5/dist/SuspenseQuery-********.cjs 630 B
packages/react-query-5/dist/SuspenseQuery-********.mjs 543 B
packages/react-query-5/dist/SuspenseQuery.cjs 105 B
packages/react-query-5/dist/SuspenseQuery.mjs 99 B
packages/react-query-5/dist/usePrefetchInfiniteQuery-********.cjs 373 B
packages/react-query-5/dist/usePrefetchInfiniteQuery-********.mjs 301 B
packages/react-query-5/dist/usePrefetchInfiniteQuery.cjs 117 B
packages/react-query-5/dist/usePrefetchInfiniteQuery.mjs 110 B
packages/react-query-5/dist/usePrefetchQuery-********.cjs 369 B
packages/react-query-5/dist/usePrefetchQuery-********.mjs 297 B
packages/react-query-5/dist/usePrefetchQuery.cjs 108 B
packages/react-query-5/dist/usePrefetchQuery.mjs 102 B
packages/react-query-5/dist/useSuspenseInfiniteQuery-********.cjs 374 B
packages/react-query-5/dist/useSuspenseInfiniteQuery-********.mjs 299 B
packages/react-query-5/dist/useSuspenseInfiniteQuery.cjs 117 B
packages/react-query-5/dist/useSuspenseInfiniteQuery.mjs 110 B
packages/react-query-5/dist/useSuspenseQueries-********.cjs 369 B
packages/react-query-5/dist/useSuspenseQueries-********.mjs 294 B
packages/react-query-5/dist/useSuspenseQueries.cjs 111 B
packages/react-query-5/dist/useSuspenseQueries.mjs 104 B
packages/react-query-5/dist/useSuspenseQuery-********.cjs 363 B
packages/react-query-5/dist/useSuspenseQuery-********.mjs 292 B
packages/react-query-5/dist/useSuspenseQuery.cjs 108 B
packages/react-query-5/dist/useSuspenseQuery.mjs 102 B
packages/react-query/dist/index.cjs 351 B
packages/react-query/dist/index.mjs 201 B
packages/react-query/dist/v4.cjs 351 B
packages/react-query/dist/v4.mjs 201 B
packages/react-query/dist/v5.cjs 351 B
packages/react-query/dist/v5.mjs 201 B
packages/react/dist/ClientOnly-********.cjs 609 B
packages/react/dist/ClientOnly-********.mjs 537 B
packages/react/dist/ClientOnly.cjs 97 B
packages/react/dist/ClientOnly.mjs 91 B
packages/react/dist/DefaultProps-********.cjs 996 B
packages/react/dist/DefaultProps.cjs 118 B
packages/react/dist/DefaultProps.mjs 114 B
packages/react/dist/DefaultPropsContexts-********.cjs 328 B
packages/react/dist/DefaultPropsContexts-********.mjs 258 B
packages/react/dist/Delay-********.cjs 1.09 kB
packages/react/dist/Delay.cjs 94 B
packages/react/dist/Delay.mjs 88 B
packages/react/dist/ErrorBoundary-********.cjs 2.15 kB
packages/react/dist/ErrorBoundary.cjs 134 B
packages/react/dist/ErrorBoundary.mjs 132 B
packages/react/dist/ErrorBoundaryGroup-********.cjs 1.19 kB
packages/react/dist/ErrorBoundaryGroup-********.mjs 1.13 kB
packages/react/dist/ErrorBoundaryGroup.cjs 133 B
packages/react/dist/ErrorBoundaryGroup.mjs 132 B
packages/react/dist/lazy-********.cjs 2.06 kB
packages/react/dist/lazy.cjs 127 B
packages/react/dist/noop-********.cjs 203 B
packages/react/dist/objectSpread2-********.cjs 805 B
packages/react/dist/objectSpread2-********.mjs 773 B
packages/react/dist/objectWithoutProperties-********.cjs 413 B
packages/react/dist/objectWithoutProperties-********.mjs 372 B
packages/react/dist/Suspense-********.cjs 909 B
packages/react/dist/Suspense-********.mjs 835 B
packages/react/dist/Suspense.cjs 98 B
packages/react/dist/Suspense.mjs 92 B
packages/react/dist/SuspenseImage-********.cjs 1.39 kB
packages/react/dist/SuspenseImage-********.mjs 1.31 kB
packages/react/dist/SuspenseImage.cjs 131 B
packages/react/dist/SuspenseImage.mjs 130 B
packages/react/dist/SuspensiveError-********.cjs 522 B
packages/react/dist/SuspensiveError-********.mjs 446 B
packages/react/dist/useIsClient-********.cjs 318 B
packages/react/dist/useIsClient.cjs 98 B
packages/react/dist/useIsClient.mjs 92 B

compressed-size-action

@codecov-commenter
Copy link

codecov-commenter commented Oct 28, 2025

Codecov Report

❌ Patch coverage is 92.85714% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.53%. Comparing base (fd886aa) to head (2ec7198).

Additional details and impacted files

Impacted file tree graph

@@            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              
Components Coverage Δ
@suspensive/react 96.07% <92.85%> (-0.57%) ⬇️
@suspensive/react-dom 100.00% <ø> (ø)
@suspensive/react-query 100.00% <ø> (ø)
@suspensive/react-query-4 100.00% <ø> (ø)
@suspensive/react-query-5 100.00% <ø> (ø)
@suspensive/jotai 100.00% <ø> (ø)
@suspensive/codemods 81.60% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@gwansikk
Copy link
Collaborator Author

gwansikk commented Jan 1, 2026

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()
Copy link
Member

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.

Copy link

Copilot AI left a 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.

Comment on lines +62 to +65
if (img.complete && img.naturalWidth > 0) {
resolve(img)
return
}
Copy link

Copilot AI Jan 7, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +74
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
Copy link

Copilot AI Jan 7, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +69
img.onerror = () => {
reject(new Error(`Failed to load image: ${src}`))
Copy link

Copilot AI Jan 7, 2026

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.

- 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.
Copy link

Copilot AI Jan 7, 2026

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".

Suggested change
- 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.

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +35
```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>
)
}
Copy link

Copilot AI Jan 7, 2026

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.

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +84
```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>
)
}
Copy link

Copilot AI Jan 7, 2026

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.

Copilot uses AI. Check for mistakes.
img.src = src

// if the image is already loaded in the browser
if (img.complete && img.naturalWidth > 0) {
Copy link

Copilot AI Jan 7, 2026

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.

Suggested change
if (img.complete && img.naturalWidth > 0) {
if (img.complete) {

Copilot uses AI. Check for mistakes.
* 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
Copy link

Copilot AI Jan 7, 2026

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.

Copilot uses AI. Check for mistakes.

- 내부 캐시를 사용하여 동일한 이미지를 여러 번 다시 로드하는 것을 방지합니다.
- Suspense를 발생시켜 이미지가 로드될 때까지 렌더링을 중단합니다.
- SSR 환경에서는 서버 렌더링된 `<img>` 태그를 반환합니다.
Copy link

Copilot AI Jan 7, 2026

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.

Suggested change
- SSR 환경에서는 서버 렌더링된 `<img>` 태그를 반환합니다.
- SSR 환경에서는 모의 `HTMLImageElement` 객체를 반환합니다.

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +35
```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>
)
}
Copy link

Copilot AI Jan 7, 2026

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: suspense support for image

4 participants