Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

- BREAKING: removed umd build
- BREAKING: Img can now accept a ref
- fix: image download is canceled on unmount
- feat: `imgPromise` can now receive an object as a second argument. An abort controller signal will be passed as `ob.signal`. This can be used to cancel the image download or other work on unmount. Please note `imgPromise()` should not reject when the abort signal is triggered.

# 4.1.0

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export default function MyComponent() {

- `srcList`: a string or array of strings. `useImage` will try loading these one at a time and returns after the first one is successfully loaded

- `imgPromise`: a promise that accepts a url and returns a promise which resolves if the image is successfully loaded or rejects if the image doesn't load. You can inject an alternative implementation for advanced custom behaviour such as logging errors or dealing with servers that return an image with a 404 header
- `imgPromise`: a function that accepts a url and an object of other options and returns a promise which resolves if the image is successfully loaded or rejects if the image doesn't load. Can be used to inject an alternative implementation for advanced custom behavior such as logging errors or dealing with servers that return an image with a 404 header. The object will contain an abort signal which can be used to cancel the image download or other work on unmount. Please note `imgPromise()` should not reject when the abort signal is triggered.

- `useSuspense`: boolean. By default, `useImage` will tell React to suspend rendering until an image is downloaded. Suspense can be disabled by setting this to false.

Expand Down
49 changes: 47 additions & 2 deletions dev/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,48 @@ const ReuseCache = ({renderId}) => {
)
}

const CancelOnUnmount = () => {
const [src] = useState(
`/delay/2000/https://picsum.photos/200?rand=${Math.random()}`,
)
const [networkCalls, setNetworkCalls] = useState(-1)
const [shouldShow, setShouldShow] = useState(true)

useEffect(() => {
setTimeout(() => {
const entires = performance.getEntriesByName(src)
setNetworkCalls(entires.length)
setShouldShow(false)
}, 500)
})

return (
<div>
<h3>Unmounted component should cancel download</h3>
<div>
{networkCalls < 0 && <span>❓ test pending</span>}
{networkCalls === 0 && <span>✅ test passed</span>}
{networkCalls > 0 && <span>❌ test failed.</span>}
</div>
<div>Network Calls detected: {networkCalls} (expecting 0)</div>
<br />
<div style={{color: 'grey'}}>
To test this manually, check the Network Tab in DevTools to ensure the
url
<code> {src} </code> is marked as canceled
</div>
<br />
<br />

{shouldShow ? (
<Img style={{width: 100, margin: '10px'}} src={src} />
) : (
<></>
)}
</div>
)
}

function ChangeSrc({renderId}) {
const getSrc = () => {
const rand = randSeconds(500, 900)
Expand Down Expand Up @@ -209,7 +251,7 @@ function ChangeSrc({renderId}) {
Src list:
{src.map((url, index) => {
return (
<div>
<div key={Math.random()}>
{index + 1}. <code>{url}</code>
</div>
)
Expand All @@ -223,6 +265,7 @@ function ChangeSrc({renderId}) {
<br />
<Img
ref={imgRef}
decode={true}
style={{width: 100}}
src={src.at(-1) as string}
loader={<div>Loading...</div>}
Expand Down Expand Up @@ -290,7 +333,6 @@ function App() {
<button onClick={() => setRenderId(Math.random())}>rerender</button>
</div>
</div>

<div className="testCases">
<div className="testCase">
<h3>Should show</h3>
Expand Down Expand Up @@ -366,6 +408,9 @@ function App() {
<HooksLegacyExample rand={rand4} />
</ErrorBoundary>
</div>
<div className="testCase">
<CancelOnUnmount key={renderId} />
</div>
</div>
</div>
<br />
Expand Down
1 change: 0 additions & 1 deletion dev/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ self.addEventListener('fetch', async (event) => {
const url = new URL(event.request.url)

if (!event.request.url.startsWith(url.origin + '/delay/')) {
console.log('not delaying', event.request.url)
return fetch(event.request)
}

Expand Down
6 changes: 5 additions & 1 deletion src/imagePromiseFactory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// returns a Promisized version of Image() api
export default ({decode = true, crossOrigin = ''}) =>
(src): Promise<void> => {
(src, {signal}): Promise<void> => {
return new Promise((resolve, reject) => {
const i = new Image()
if (crossOrigin) i.crossOrigin = crossOrigin
Expand All @@ -9,5 +9,9 @@ export default ({decode = true, crossOrigin = ''}) =>
}
i.onerror = reject
i.src = src
signal.addEventListener('abort', () => {
i.src = ''
resolve()
})
})
}
16 changes: 12 additions & 4 deletions src/useImage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useState} from 'react'
import {useState, useEffect} from 'react'
import imagePromiseFactory from './imagePromiseFactory'

export type useImageProps = {
Expand All @@ -12,11 +12,11 @@ const stringToArray = (x) => (Array.isArray(x) ? x : [x])
const cache = {}

// sequential map.find for promises
const promiseFind = (arr, promiseFactory) => {
const promiseFind = (arr, promiseFactory, signal) => {
let done = false
return new Promise((resolve, reject) => {
const queueNext = (src) => {
return promiseFactory(src).then(() => {
return promiseFactory(src, {signal}).then(() => {
done = true
resolve(src)
})
Expand All @@ -42,12 +42,20 @@ export default function useImage({
const sourceList = removeBlankArrayElements(stringToArray(srcList))
const sourceKey = sourceList.join('')

// on unmount, cancel any pending requests
useEffect(() => () => {
cache[sourceKey]?.controller.abort()
})

if (!cache[sourceKey]) {
// create promise to loop through sources and try to load one
const controller = new AbortController()
const signal = controller.signal
cache[sourceKey] = {
promise: promiseFind(sourceList, imgPromise),
promise: promiseFind(sourceList, imgPromise, signal),
cache: 'pending',
error: null,
controller,
}
}

Expand Down