Skip to content

Commit 4e71c18

Browse files
authored
Merge pull request #504 from pixijs/501-feature-request-error-handling-for-useasset
Add `onError` callback to `useAsset`
2 parents 8a317c1 + 8c78809 commit 4e71c18

13 files changed

+349
-38
lines changed

README.md

+31-16
Original file line numberDiff line numberDiff line change
@@ -270,49 +270,64 @@ const ParentComponent = () => (
270270

271271
#### `useAsset`
272272

273-
The `useAsset` hook wraps the functionality of [Pixi's Asset loader](https://pixijs.download/release/docs/assets.Assets.html) and cache into a convenient React hook. The hook can accept either an [`UnresolvedAsset`](https://pixijs.download/release/docs/assets.html#UnresolvedAsset) or a url.
273+
**DEPRECATED.** Use `useAssets` of `useSuspenseAssets` instead.
274+
275+
#### `useAssets`
276+
277+
The `useAssets` hook wraps the functionality of [Pixi's Asset loader](https://pixijs.download/release/docs/assets.Assets.html) and [Cache](https://pixijs.download/release/docs/assets.Cache.html) into a convenient React hook. The hook can accept an array of items which are either an [`UnresolvedAsset`](https://pixijs.download/release/docs/assets.html#UnresolvedAsset) or a url.
274278

275279
```jsx
276-
import { useAsset } from '@pixi/react'
280+
import { useAssets } from '@pixi/react'
277281

278282
const MyComponent = () => {
279-
const bunnyTexture = useAsset('https://pixijs.com/assets/bunny.png')
280-
const bunnyTexture2 = useAsset({
281-
alias: 'bunny',
282-
src: 'https://pixijs.com/assets/bunny.png',
283-
})
283+
const {
284+
assets: [
285+
bunnyTexture1,
286+
bunnyTexture2,
287+
],
288+
isSuccess,
289+
} = useAssets([
290+
'https://pixijs.com/assets/bunny.png',
291+
{
292+
alias: 'bunny',
293+
src: 'https://pixijs.com/assets/bunny.png',
294+
}
295+
])
284296

285297
return (
286298
<container>
287-
<sprite texture={bunnyTexture}>
288-
<sprite texture={bunnyTexture2}>
299+
{isSuccess && (
300+
<sprite texture={bunnyTexture}>
301+
<sprite texture={bunnyTexture2}>
302+
)}
289303
</container>
290304
)
291305
}
292306
```
293307

294308
##### Tracking Progress
295309

296-
`useAsset` can optionally accept a [`ProgressCallback`](https://pixijs.download/release/docs/assets.html#ProgressCallback) as a second argument. This callback will be called by the asset loader as the asset is loaded.
310+
`useAssets` can optionally accept a [`ProgressCallback`](https://pixijs.download/release/docs/assets.html#ProgressCallback) as a second argument. This callback will be called by the asset loader as the asset is loaded.
297311

298312
```jsx
299-
const bunnyTexture = useAsset('https://pixijs.com/assets/bunny.png', progress => {
313+
const bunnyTexture = useAssets('https://pixijs.com/assets/bunny.png', progress => {
300314
console.log(`We have achieved ${progress * 100}% bunny.`)
301315
})
302316
```
303317

304-
> [!TIP]
305-
> The `useAsset` hook also supports [React Suspense](https://react.dev/reference/react/Suspense)! If given a suspense boundary, it's possible to prevent components from rendering until they've finished loading their assets:
318+
#### `useSuspenseAssets`
319+
320+
`useSuspenseAssets` is similar to the `useAssets` hook, except that it supports [React Suspense](https://react.dev/reference/react/Suspense). `useSuspenseAssets` accepts the same parameters as `useAssets`, but it only returns an array of the loaded assets. This is because given a suspense boundary it's possible to prevent components from rendering until they've finished loading their assets.
306321
> ```jsx
307322
> import {
308323
> Application,
309-
> useAsset,
324+
> useSuspenseAssets,
310325
> } from '@pixi/react'
311326
>
312-
> import { Suspense } from 'react';
327+
> import { Suspense } from 'react'
313328
>
314329
> const BunnySprite = () => {
315-
> const bunnyTexture = useAsset('https://pixijs.com/assets/bunny.png')
330+
> const [bunnyTexture] = useSuspenseAssets(['https://pixijs.com/assets/bunny.png'])
316331
>
317332
> return (
318333
> <sprite texture={bunnyTexture} />

src/constants/UseAssetsStatus.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export const UseAssetsStatus: Record<string, 'error' | 'pending' | 'success'> = {
2+
ERROR: 'error',
3+
PENDING: 'pending',
4+
SUCCESS: 'success',
5+
};

src/helpers/getAssetKey.ts

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { UnresolvedAsset } from '../typedefs/UnresolvedAsset';
2+
3+
/** Retrieves the key from an unresolved asset. */
4+
export function getAssetKey<T>(asset: UnresolvedAsset<T>)
5+
{
6+
let assetKey;
7+
8+
if (typeof asset === 'string')
9+
{
10+
assetKey = asset;
11+
}
12+
else
13+
{
14+
assetKey = (asset.alias ?? asset.src) as string;
15+
}
16+
17+
return assetKey;
18+
}

src/helpers/getAssetKeyFromOptions.ts

-18
This file was deleted.

src/hooks/useAsset.ts

+21-4
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,37 @@ import {
22
Assets,
33
Cache,
44
} from 'pixi.js';
5-
import { getAssetKeyFromOptions } from '../helpers/getAssetKeyFromOptions.ts';
5+
import { getAssetKey } from '../helpers/getAssetKey.ts';
66

77
import type {
88
ProgressCallback,
99
UnresolvedAsset,
1010
} from 'pixi.js';
1111
import type { AssetRetryOptions } from '../typedefs/AssetRetryOptions.ts';
1212
import type { AssetRetryState } from '../typedefs/AssetRetryState.ts';
13+
import type { ErrorCallback } from '../typedefs/ErrorCallback.ts';
1314

1415
const errorCache: Map<UnresolvedAsset | string, AssetRetryState> = new Map();
1516

16-
/** Loads assets, returning a hash of assets once they're loaded. */
17+
/** @deprecated Use `useAssets` instead. */
1718
export function useAsset<T>(
1819
/** @description Asset options. */
1920
options: (UnresolvedAsset<T> & AssetRetryOptions) | string,
2021
/** @description A function to be called when the asset loader reports loading progress. */
2122
onProgress?: ProgressCallback,
23+
/** @description A function to be called when the asset loader reports loading progress. */
24+
onError?: ErrorCallback,
2225
)
2326
{
2427
if (typeof window === 'undefined')
2528
{
29+
/**
30+
* This is a weird hack that allows us to throw the error during
31+
* serverside rendering, but still causes it to be handled appropriately
32+
* in Next.js applications.
33+
*
34+
* @see https://github.com/vercel/next.js/blob/38b3423160afc572ad933c24c86fc572c584e70b/packages/next/src/shared/lib/lazy-dynamic/bailout-to-csr.ts
35+
*/
2636
throw Object.assign(Error('`useAsset` will only run on the client.'), {
2737
digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING',
2838
});
@@ -33,7 +43,7 @@ export function useAsset<T>(
3343
retryOnFailure = true,
3444
} = typeof options !== 'string' ? options : {};
3545

36-
const assetKey = getAssetKeyFromOptions(options);
46+
const assetKey = getAssetKey(options);
3747

3848
if (!Cache.has(assetKey))
3949
{
@@ -42,7 +52,14 @@ export function useAsset<T>(
4252
// Rethrow the cached error if we are not retrying on failure or have reached the max retries
4353
if (state && (!retryOnFailure || state.retries > maxRetries))
4454
{
45-
throw state.error;
55+
if (typeof onError === 'function')
56+
{
57+
onError?.(state.error);
58+
}
59+
else
60+
{
61+
throw state.error;
62+
}
4663
}
4764

4865
throw Assets

src/hooks/useAssets.ts

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {
2+
Assets,
3+
Cache,
4+
} from 'pixi.js';
5+
import { useState } from 'react';
6+
import { UseAssetsStatus } from '../constants/UseAssetsStatus.ts';
7+
import { getAssetKey } from '../helpers/getAssetKey.ts';
8+
9+
import type { AssetRetryState } from '../typedefs/AssetRetryState.ts';
10+
import type { UnresolvedAsset } from '../typedefs/UnresolvedAsset.ts';
11+
import type { UseAssetsOptions } from '../typedefs/UseAssetsOptions.ts';
12+
import type { UseAssetsResult } from '../typedefs/UseAssetsResult.ts';
13+
14+
const errorCache: Map<UnresolvedAsset, AssetRetryState> = new Map();
15+
16+
function assetsLoadedTest<T>(asset: UnresolvedAsset<T>)
17+
{
18+
return Cache.has(getAssetKey(asset));
19+
}
20+
21+
/** Loads assets, returning a hash of assets once they're loaded. */
22+
export function useAssets<T = any>(
23+
/** @description Assets to be loaded. */
24+
assets: UnresolvedAsset<T>[],
25+
26+
/** @description Asset options. */
27+
options: UseAssetsOptions = {},
28+
): UseAssetsResult<T>
29+
{
30+
const [state, setState] = useState<UseAssetsResult<T>>({
31+
assets: Array(assets.length).fill(null),
32+
isError: false,
33+
isPending: true,
34+
isSuccess: false,
35+
status: UseAssetsStatus.PENDING,
36+
});
37+
38+
if (typeof window === 'undefined')
39+
{
40+
return state;
41+
}
42+
43+
const {
44+
maxRetries = 3,
45+
onError,
46+
onProgress,
47+
retryOnFailure = true,
48+
} = options;
49+
50+
const allAssetsAreLoaded = assets.some(assetsLoadedTest<T>);
51+
52+
if (!allAssetsAreLoaded)
53+
{
54+
let cachedState = errorCache.get(assets);
55+
56+
// Rethrow the cached error if we are not retrying on failure or have reached the max retries
57+
if (cachedState && (!retryOnFailure || cachedState.retries > maxRetries))
58+
{
59+
if (typeof onError === 'function')
60+
{
61+
onError(cachedState.error);
62+
}
63+
64+
setState((previousState) => ({
65+
...previousState,
66+
error: cachedState?.error,
67+
isError: true,
68+
isPending: false,
69+
isSuccess: false,
70+
status: UseAssetsStatus.ERROR,
71+
}));
72+
}
73+
74+
Assets.load<T>(assets, (progressValue) =>
75+
{
76+
if (typeof onProgress === 'function')
77+
{
78+
onProgress(progressValue);
79+
}
80+
})
81+
.then(() =>
82+
{
83+
const assetKeys = assets.map((asset: UnresolvedAsset<T>) => getAssetKey(asset));
84+
const resolvedAssetsDictionary = Assets.get<T>(assetKeys) as Record<string, T>;
85+
86+
setState((previousState) => ({
87+
...previousState,
88+
assets: assets.map((_asset: UnresolvedAsset<T>, index: number) => resolvedAssetsDictionary[index]),
89+
isError: false,
90+
isPending: false,
91+
isSuccess: true,
92+
status: UseAssetsStatus.SUCCESS,
93+
}));
94+
})
95+
.catch((error) =>
96+
{
97+
if (!cachedState)
98+
{
99+
cachedState = {
100+
error,
101+
retries: 0,
102+
};
103+
}
104+
105+
errorCache.set(assets, {
106+
...cachedState,
107+
error,
108+
retries: cachedState.retries + 1,
109+
});
110+
});
111+
}
112+
113+
return state;
114+
}

src/hooks/useSuspenseAssets.ts

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import {
2+
Assets,
3+
Cache,
4+
} from 'pixi.js';
5+
import { getAssetKey } from '../helpers/getAssetKey.ts';
6+
7+
import type { AssetRetryState } from '../typedefs/AssetRetryState.ts';
8+
import type { UnresolvedAsset } from '../typedefs/UnresolvedAsset.ts';
9+
import type { UseAssetsOptions } from '../typedefs/UseAssetsOptions.ts';
10+
11+
const errorCache: Map<UnresolvedAsset, AssetRetryState> = new Map();
12+
13+
function assetsLoadedTest<T>(asset: UnresolvedAsset<T>)
14+
{
15+
return Cache.has(getAssetKey(asset));
16+
}
17+
18+
/** Loads assets, returning a hash of assets once they're loaded. Must be inside of a `<Suspense>` component. */
19+
export function useSuspenseAssets<T = any>(
20+
/** @description Assets to be loaded. */
21+
assets: UnresolvedAsset<T>[],
22+
/** @description Asset options. */
23+
options: UseAssetsOptions = {},
24+
): T[]
25+
{
26+
if (typeof window === 'undefined')
27+
{
28+
throw Object.assign(Error('`useAssets` will only run on the client.'), {
29+
digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING',
30+
});
31+
}
32+
33+
const {
34+
maxRetries = 3,
35+
onError,
36+
onProgress,
37+
retryOnFailure = true,
38+
} = options;
39+
40+
const allAssetsAreLoaded = assets.some(assetsLoadedTest<T>);
41+
42+
if (!allAssetsAreLoaded)
43+
{
44+
let cachedState = errorCache.get(assets);
45+
46+
// Rethrow the cached error if we are not retrying on failure or have reached the max retries
47+
if (cachedState && (!retryOnFailure || cachedState.retries > maxRetries))
48+
{
49+
if (typeof onError === 'function')
50+
{
51+
onError(cachedState.error);
52+
}
53+
else
54+
{
55+
throw cachedState.error;
56+
}
57+
}
58+
59+
throw Assets
60+
.load<T>(assets, (progressValue) =>
61+
{
62+
if (typeof onProgress === 'function')
63+
{
64+
onProgress(progressValue);
65+
}
66+
})
67+
.catch((error) =>
68+
{
69+
if (!cachedState)
70+
{
71+
cachedState = {
72+
error,
73+
retries: 0,
74+
};
75+
}
76+
77+
errorCache.set(assets, {
78+
...cachedState,
79+
error,
80+
retries: cachedState.retries + 1,
81+
});
82+
});
83+
}
84+
85+
const assetKeys = assets.map((asset: UnresolvedAsset<T>) => getAssetKey(asset));
86+
const resolvedAssetsDictionary = Assets.get<T>(assetKeys) as Record<string, T>;
87+
88+
return assets.map((_asset: UnresolvedAsset<T>, index: number) => resolvedAssetsDictionary[index]);
89+
}

0 commit comments

Comments
 (0)