Skip to content

Commit

Permalink
Merge pull request #504 from pixijs/501-feature-request-error-handlin…
Browse files Browse the repository at this point in the history
…g-for-useasset

Add `onError` callback to `useAsset`
  • Loading branch information
trezy authored Aug 1, 2024
2 parents 8a317c1 + 8c78809 commit 4e71c18
Show file tree
Hide file tree
Showing 13 changed files with 349 additions and 38 deletions.
47 changes: 31 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,49 +270,64 @@ const ParentComponent = () => (

#### `useAsset`

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.
**DEPRECATED.** Use `useAssets` of `useSuspenseAssets` instead.

#### `useAssets`

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.

```jsx
import { useAsset } from '@pixi/react'
import { useAssets } from '@pixi/react'

const MyComponent = () => {
const bunnyTexture = useAsset('https://pixijs.com/assets/bunny.png')
const bunnyTexture2 = useAsset({
alias: 'bunny',
src: 'https://pixijs.com/assets/bunny.png',
})
const {
assets: [
bunnyTexture1,
bunnyTexture2,
],
isSuccess,
} = useAssets([
'https://pixijs.com/assets/bunny.png',
{
alias: 'bunny',
src: 'https://pixijs.com/assets/bunny.png',
}
])

return (
<container>
<sprite texture={bunnyTexture}>
<sprite texture={bunnyTexture2}>
{isSuccess && (
<sprite texture={bunnyTexture}>
<sprite texture={bunnyTexture2}>
)}
</container>
)
}
```

##### Tracking Progress

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

```jsx
const bunnyTexture = useAsset('https://pixijs.com/assets/bunny.png', progress => {
const bunnyTexture = useAssets('https://pixijs.com/assets/bunny.png', progress => {
console.log(`We have achieved ${progress * 100}% bunny.`)
})
```

> [!TIP]
> 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:
#### `useSuspenseAssets`

`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.
> ```jsx
> import {
> Application,
> useAsset,
> useSuspenseAssets,
> } from '@pixi/react'
>
> import { Suspense } from 'react';
> import { Suspense } from 'react'
>
> const BunnySprite = () => {
> const bunnyTexture = useAsset('https://pixijs.com/assets/bunny.png')
> const [bunnyTexture] = useSuspenseAssets(['https://pixijs.com/assets/bunny.png'])
>
> return (
> <sprite texture={bunnyTexture} />
Expand Down
5 changes: 5 additions & 0 deletions src/constants/UseAssetsStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const UseAssetsStatus: Record<string, 'error' | 'pending' | 'success'> = {
ERROR: 'error',
PENDING: 'pending',
SUCCESS: 'success',
};
18 changes: 18 additions & 0 deletions src/helpers/getAssetKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { UnresolvedAsset } from '../typedefs/UnresolvedAsset';

/** Retrieves the key from an unresolved asset. */
export function getAssetKey<T>(asset: UnresolvedAsset<T>)
{
let assetKey;

if (typeof asset === 'string')
{
assetKey = asset;
}
else
{
assetKey = (asset.alias ?? asset.src) as string;
}

return assetKey;
}
18 changes: 0 additions & 18 deletions src/helpers/getAssetKeyFromOptions.ts

This file was deleted.

25 changes: 21 additions & 4 deletions src/hooks/useAsset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,37 @@ import {
Assets,
Cache,
} from 'pixi.js';
import { getAssetKeyFromOptions } from '../helpers/getAssetKeyFromOptions.ts';
import { getAssetKey } from '../helpers/getAssetKey.ts';

import type {
ProgressCallback,
UnresolvedAsset,
} from 'pixi.js';
import type { AssetRetryOptions } from '../typedefs/AssetRetryOptions.ts';
import type { AssetRetryState } from '../typedefs/AssetRetryState.ts';
import type { ErrorCallback } from '../typedefs/ErrorCallback.ts';

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

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

const assetKey = getAssetKeyFromOptions(options);
const assetKey = getAssetKey(options);

if (!Cache.has(assetKey))
{
Expand All @@ -42,7 +52,14 @@ export function useAsset<T>(
// Rethrow the cached error if we are not retrying on failure or have reached the max retries
if (state && (!retryOnFailure || state.retries > maxRetries))
{
throw state.error;
if (typeof onError === 'function')
{
onError?.(state.error);
}
else
{
throw state.error;
}
}

throw Assets
Expand Down
114 changes: 114 additions & 0 deletions src/hooks/useAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
Assets,
Cache,
} from 'pixi.js';
import { useState } from 'react';
import { UseAssetsStatus } from '../constants/UseAssetsStatus.ts';
import { getAssetKey } from '../helpers/getAssetKey.ts';

import type { AssetRetryState } from '../typedefs/AssetRetryState.ts';
import type { UnresolvedAsset } from '../typedefs/UnresolvedAsset.ts';
import type { UseAssetsOptions } from '../typedefs/UseAssetsOptions.ts';
import type { UseAssetsResult } from '../typedefs/UseAssetsResult.ts';

const errorCache: Map<UnresolvedAsset, AssetRetryState> = new Map();

function assetsLoadedTest<T>(asset: UnresolvedAsset<T>)
{
return Cache.has(getAssetKey(asset));
}

/** Loads assets, returning a hash of assets once they're loaded. */
export function useAssets<T = any>(
/** @description Assets to be loaded. */
assets: UnresolvedAsset<T>[],

/** @description Asset options. */
options: UseAssetsOptions = {},
): UseAssetsResult<T>
{
const [state, setState] = useState<UseAssetsResult<T>>({
assets: Array(assets.length).fill(null),
isError: false,
isPending: true,
isSuccess: false,
status: UseAssetsStatus.PENDING,
});

if (typeof window === 'undefined')
{
return state;
}

const {
maxRetries = 3,
onError,
onProgress,
retryOnFailure = true,
} = options;

const allAssetsAreLoaded = assets.some(assetsLoadedTest<T>);

if (!allAssetsAreLoaded)
{
let cachedState = errorCache.get(assets);

// Rethrow the cached error if we are not retrying on failure or have reached the max retries
if (cachedState && (!retryOnFailure || cachedState.retries > maxRetries))
{
if (typeof onError === 'function')
{
onError(cachedState.error);
}

setState((previousState) => ({
...previousState,
error: cachedState?.error,
isError: true,
isPending: false,
isSuccess: false,
status: UseAssetsStatus.ERROR,
}));
}

Assets.load<T>(assets, (progressValue) =>
{
if (typeof onProgress === 'function')
{
onProgress(progressValue);
}
})
.then(() =>
{
const assetKeys = assets.map((asset: UnresolvedAsset<T>) => getAssetKey(asset));
const resolvedAssetsDictionary = Assets.get<T>(assetKeys) as Record<string, T>;

setState((previousState) => ({
...previousState,
assets: assets.map((_asset: UnresolvedAsset<T>, index: number) => resolvedAssetsDictionary[index]),
isError: false,
isPending: false,
isSuccess: true,
status: UseAssetsStatus.SUCCESS,
}));
})
.catch((error) =>
{
if (!cachedState)
{
cachedState = {
error,
retries: 0,
};
}

errorCache.set(assets, {
...cachedState,
error,
retries: cachedState.retries + 1,
});
});
}

return state;
}
89 changes: 89 additions & 0 deletions src/hooks/useSuspenseAssets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
Assets,
Cache,
} from 'pixi.js';
import { getAssetKey } from '../helpers/getAssetKey.ts';

import type { AssetRetryState } from '../typedefs/AssetRetryState.ts';
import type { UnresolvedAsset } from '../typedefs/UnresolvedAsset.ts';
import type { UseAssetsOptions } from '../typedefs/UseAssetsOptions.ts';

const errorCache: Map<UnresolvedAsset, AssetRetryState> = new Map();

function assetsLoadedTest<T>(asset: UnresolvedAsset<T>)
{
return Cache.has(getAssetKey(asset));
}

/** Loads assets, returning a hash of assets once they're loaded. Must be inside of a `<Suspense>` component. */
export function useSuspenseAssets<T = any>(
/** @description Assets to be loaded. */
assets: UnresolvedAsset<T>[],
/** @description Asset options. */
options: UseAssetsOptions = {},
): T[]
{
if (typeof window === 'undefined')
{
throw Object.assign(Error('`useAssets` will only run on the client.'), {
digest: 'BAILOUT_TO_CLIENT_SIDE_RENDERING',
});
}

const {
maxRetries = 3,
onError,
onProgress,
retryOnFailure = true,
} = options;

const allAssetsAreLoaded = assets.some(assetsLoadedTest<T>);

if (!allAssetsAreLoaded)
{
let cachedState = errorCache.get(assets);

// Rethrow the cached error if we are not retrying on failure or have reached the max retries
if (cachedState && (!retryOnFailure || cachedState.retries > maxRetries))
{
if (typeof onError === 'function')
{
onError(cachedState.error);
}
else
{
throw cachedState.error;
}
}

throw Assets
.load<T>(assets, (progressValue) =>
{
if (typeof onProgress === 'function')
{
onProgress(progressValue);
}
})
.catch((error) =>
{
if (!cachedState)
{
cachedState = {
error,
retries: 0,
};
}

errorCache.set(assets, {
...cachedState,
error,
retries: cachedState.retries + 1,
});
});
}

const assetKeys = assets.map((asset: UnresolvedAsset<T>) => getAssetKey(asset));
const resolvedAssetsDictionary = Assets.get<T>(assetKeys) as Record<string, T>;

return assets.map((_asset: UnresolvedAsset<T>, index: number) => resolvedAssetsDictionary[index]);
}
Loading

0 comments on commit 4e71c18

Please sign in to comment.