diff --git a/CHANGELOG.md b/CHANGELOG.md index 45d553cf..41fe37ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 97c477d6..45c44363 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/dev/app.tsx b/dev/app.tsx index bddb5b9d..bfeac5b3 100644 --- a/dev/app.tsx +++ b/dev/app.tsx @@ -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 ( +
+

Unmounted component should cancel download

+
+ {networkCalls < 0 && ❓ test pending} + {networkCalls === 0 && ✅ test passed} + {networkCalls > 0 && ❌ test failed.} +
+
Network Calls detected: {networkCalls} (expecting 0)
+
+
+ To test this manually, check the Network Tab in DevTools to ensure the + url + {src} is marked as canceled +
+
+
+ + {shouldShow ? ( + + ) : ( + <> + )} +
+ ) +} + function ChangeSrc({renderId}) { const getSrc = () => { const rand = randSeconds(500, 900) @@ -209,7 +251,7 @@ function ChangeSrc({renderId}) { Src list: {src.map((url, index) => { return ( -
+
{index + 1}. {url}
) @@ -223,6 +265,7 @@ function ChangeSrc({renderId}) {
Loading...
} @@ -290,7 +333,6 @@ function App() { -

Should show

@@ -366,6 +408,9 @@ function App() {
+
+ +

diff --git a/dev/sw.js b/dev/sw.js index 7241d969..3e123171 100644 --- a/dev/sw.js +++ b/dev/sw.js @@ -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) } diff --git a/src/imagePromiseFactory.ts b/src/imagePromiseFactory.ts index c7a37bc5..72b788a3 100644 --- a/src/imagePromiseFactory.ts +++ b/src/imagePromiseFactory.ts @@ -1,6 +1,6 @@ // returns a Promisized version of Image() api export default ({decode = true, crossOrigin = ''}) => - (src): Promise => { + (src, {signal}): Promise => { return new Promise((resolve, reject) => { const i = new Image() if (crossOrigin) i.crossOrigin = crossOrigin @@ -9,5 +9,9 @@ export default ({decode = true, crossOrigin = ''}) => } i.onerror = reject i.src = src + signal.addEventListener('abort', () => { + i.src = '' + resolve() + }) }) } diff --git a/src/useImage.tsx b/src/useImage.tsx index 5fdfa215..937b71b6 100644 --- a/src/useImage.tsx +++ b/src/useImage.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react' +import {useState, useEffect} from 'react' import imagePromiseFactory from './imagePromiseFactory' export type useImageProps = { @@ -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) }) @@ -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, } }