Skip to content

feat(react): add useSuspenseImage, <SuspenseImage/>#1774

Closed
gwansikk wants to merge 24 commits intomainfrom
feature/suspense-image
Closed

feat(react): add useSuspenseImage, <SuspenseImage/>#1774
gwansikk wants to merge 24 commits intomainfrom
feature/suspense-image

Conversation

@gwansikk
Copy link
Copy Markdown
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
Copy Markdown

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
Copy Markdown

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 (comment) #1774 (comment) #1774 (review) #1774 (review) #1774 #1774 (review) #1774 (review) #1774 (review) #1774 (comment) 12 Co-authored-by: gwansikk <39869096+gwansikk@users.noreply.github.com>
@manudeli #1774 (comment) #1774 (comment) #1774 (comment) #1774 (comment) #1774 (comment) #1774 (review) #1774 (review) #1774 (review) #1774 (review) #1774 (review) #1774 (comment) 11 Co-authored-by: manudeli <61593290+manudeli@users.noreply.github.com>
@Copilot #1774 (comment) #1774 (comment) #1774 (comment) #1774 (comment) #1774 (comment) #1774 (comment) #1774 (comment) #1774 (comment) #1774 (comment) #1774 (comment) #1774 (comment) 11 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@codecov-commenter #1774 (comment) 1 Co-authored-by: codecov-commenter <65553080+codecov-commenter@users.noreply.github.com>

@vercel
Copy link
Copy Markdown
Contributor

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 thread packages/react/src/AwaitImage.tsx Outdated
Comment thread packages/react/src/SuspenseImage.tsx Outdated
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown

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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Member

Choose a reason for hiding this comment

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

@gwansikk I think you just missed this comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

@gwansikk gwansikk Jan 11, 2026

Choose a reason for hiding this comment

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

KOR

new Image()는 단순한 이미지 로더가 아니라, 브라우저 렌더링 파이프라인(네트워크, 캐시, 디코딩)에 이미지를 명시적으로 등록하는 Web Platform API입니다.
이 방식을 사용하면 Suspense가 해제되는 시점이 <img src="...">가 실제로 paint 가능한 시점과 최대한 일치하게 됩니다.
즉, “Suspense가 풀린 시점 = 브라우저에서 이미지가 준비된 시점”이라는 의미적 보장을 할 수 있습니다. 이 보장은 { src } 객체나 fetch 기반 접근으로는 얻기 어렵습니다.

(개인적으로는 new Image를 모킹한 객체를 만들어 해결하는 방식은 의미적으로 맞지 않다고 봅니다.
new Image()는 단순 객체 생성이 아니라, 브라우저 렌더링 파이프라인과 직접 상호작용하는 강력한 API이기 때문입니다.)

ref:

문제는 new Image()가 Web 전용 API라는 점입니다. React Native에는 DOM이나 HTMLImageElement 개념이 없기 때문에, Web과 RN에서 동일한 구현을 공유하는 것은 구조적으로 불가능합니다. RN에서는 의미적으로 대응되는 수단이 Image.prefetch 정도입니다.

그래서 모든 플랫폼을 지원하려면 현실적인 선택지는 두 가지로 보입니다.

  1. 라이브러리가 Web / RN 구현을 모두 제공하는 방식
├─ ImageView.web.tsx
├─ ImageView.native.tsx
  • Web: new Image()
  • RN: Image.prefetch
  • .web.tsx / .native.tsx로 분리
  • 장점: 사용자 설정 없이 바로 사용 가능, Suspense 해제 의미를 라이브러리가 일관되게 보장
  • 단점: RN 구현 책임 + react-native peerDependency 필요
    (RN 쪽 구현을 별도 유틸/패키지로 분리하는 선택지도 가능)
  1. 라이브러리는 추상 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:

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:

  1. 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-native as a peerDependency
    (You could also extract the RN implementation into a separate utility or package)
  1. 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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:

  1. Suspensive <InView/> is not better than react-intersection-observer, When I first make <InView/> of Suspensive, I wanna support isomorphic InView for react-dom and react-native both supported. but I couldn't do it well
  2. <FadeIn/> could be better to give implementation to library user itself

Copy link
Copy Markdown

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.
Comment thread docs/suspensive.org/src/content/en/docs/react/SuspenseImage.mdx
Comment thread docs/suspensive.org/src/content/en/docs/react/SuspenseImage.mdx
Comment thread docs/suspensive.org/src/content/ko/docs/react/SuspenseImage.mdx
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.
Comment thread packages/react/src/SuspenseImage.tsx
Comment thread docs/suspensive.org/src/content/ko/docs/react/SuspenseImage.mdx
Comment thread docs/suspensive.org/src/content/ko/docs/react/SuspenseImage.mdx
@manudeli
Copy link
Copy Markdown
Member

@gwansikk After we discussion in call, we close this PR

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