Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add onError callback to useAsset #504

Merged
merged 12 commits into from
Aug 1, 2024
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