diff --git a/README.md b/README.md
index 0860493d..4b3ca335 100644
--- a/README.md
+++ b/README.md
@@ -270,22 +270,36 @@ 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 (
-
-
+ {isSuccess && (
+
+
+ )}
)
}
@@ -293,26 +307,27 @@ const MyComponent = () => {
##### 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 (
>
diff --git a/src/constants/UseAssetsStatus.ts b/src/constants/UseAssetsStatus.ts
new file mode 100644
index 00000000..e6cb8e4f
--- /dev/null
+++ b/src/constants/UseAssetsStatus.ts
@@ -0,0 +1,5 @@
+export const UseAssetsStatus: Record = {
+ ERROR: 'error',
+ PENDING: 'pending',
+ SUCCESS: 'success',
+};
diff --git a/src/helpers/getAssetKey.ts b/src/helpers/getAssetKey.ts
new file mode 100644
index 00000000..ef58e123
--- /dev/null
+++ b/src/helpers/getAssetKey.ts
@@ -0,0 +1,18 @@
+import type { UnresolvedAsset } from '../typedefs/UnresolvedAsset';
+
+/** Retrieves the key from an unresolved asset. */
+export function getAssetKey(asset: UnresolvedAsset)
+{
+ let assetKey;
+
+ if (typeof asset === 'string')
+ {
+ assetKey = asset;
+ }
+ else
+ {
+ assetKey = (asset.alias ?? asset.src) as string;
+ }
+
+ return assetKey;
+}
diff --git a/src/helpers/getAssetKeyFromOptions.ts b/src/helpers/getAssetKeyFromOptions.ts
deleted file mode 100644
index eac46db0..00000000
--- a/src/helpers/getAssetKeyFromOptions.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { UnresolvedAsset } from 'pixi.js';
-
-/** Loads assets, returning a hash of assets once they're loaded. */
-export function getAssetKeyFromOptions(options: UnresolvedAsset | string)
-{
- let assetKey;
-
- if (typeof options === 'string')
- {
- assetKey = options;
- }
- else
- {
- assetKey = (options.alias ?? options.src) as string;
- }
-
- return assetKey;
-}
diff --git a/src/hooks/useAsset.ts b/src/hooks/useAsset.ts
index 248fd263..c647120d 100644
--- a/src/hooks/useAsset.ts
+++ b/src/hooks/useAsset.ts
@@ -2,7 +2,7 @@ import {
Assets,
Cache,
} from 'pixi.js';
-import { getAssetKeyFromOptions } from '../helpers/getAssetKeyFromOptions.ts';
+import { getAssetKey } from '../helpers/getAssetKey.ts';
import type {
ProgressCallback,
@@ -10,19 +10,29 @@ import type {
} 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 = new Map();
-/** Loads assets, returning a hash of assets once they're loaded. */
+/** @deprecated Use `useAssets` instead. */
export function useAsset(
/** @description Asset options. */
options: (UnresolvedAsset & 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',
});
@@ -33,7 +43,7 @@ export function useAsset(
retryOnFailure = true,
} = typeof options !== 'string' ? options : {};
- const assetKey = getAssetKeyFromOptions(options);
+ const assetKey = getAssetKey(options);
if (!Cache.has(assetKey))
{
@@ -42,7 +52,14 @@ export function useAsset(
// 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
diff --git a/src/hooks/useAssets.ts b/src/hooks/useAssets.ts
new file mode 100644
index 00000000..d276bae9
--- /dev/null
+++ b/src/hooks/useAssets.ts
@@ -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 = new Map();
+
+function assetsLoadedTest(asset: UnresolvedAsset)
+{
+ return Cache.has(getAssetKey(asset));
+}
+
+/** Loads assets, returning a hash of assets once they're loaded. */
+export function useAssets(
+ /** @description Assets to be loaded. */
+ assets: UnresolvedAsset[],
+
+ /** @description Asset options. */
+ options: UseAssetsOptions = {},
+): UseAssetsResult
+{
+ const [state, setState] = useState>({
+ 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);
+
+ 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(assets, (progressValue) =>
+ {
+ if (typeof onProgress === 'function')
+ {
+ onProgress(progressValue);
+ }
+ })
+ .then(() =>
+ {
+ const assetKeys = assets.map((asset: UnresolvedAsset) => getAssetKey(asset));
+ const resolvedAssetsDictionary = Assets.get(assetKeys) as Record;
+
+ setState((previousState) => ({
+ ...previousState,
+ assets: assets.map((_asset: UnresolvedAsset, 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;
+}
diff --git a/src/hooks/useSuspenseAssets.ts b/src/hooks/useSuspenseAssets.ts
new file mode 100644
index 00000000..f3e24eda
--- /dev/null
+++ b/src/hooks/useSuspenseAssets.ts
@@ -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 = new Map();
+
+function assetsLoadedTest(asset: UnresolvedAsset)
+{
+ return Cache.has(getAssetKey(asset));
+}
+
+/** Loads assets, returning a hash of assets once they're loaded. Must be inside of a `` component. */
+export function useSuspenseAssets(
+ /** @description Assets to be loaded. */
+ assets: UnresolvedAsset[],
+ /** @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);
+
+ 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(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) => getAssetKey(asset));
+ const resolvedAssetsDictionary = Assets.get(assetKeys) as Record;
+
+ return assets.map((_asset: UnresolvedAsset, index: number) => resolvedAssetsDictionary[index]);
+}
diff --git a/src/index.ts b/src/index.ts
index 16f05b4b..4658bc50 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,11 +6,14 @@ Be aware that you are using a beta version of Pixi React.
`);
export { Application } from './components/Application.ts';
+export { UseAssetsStatus } from './constants/UseAssetsStatus.ts';
export { createRoot } from './core/createRoot.ts';
export * from './global.ts';
export { extend } from './helpers/extend.ts';
export { useApp } from './hooks/useApp.ts';
export { useApplication } from './hooks/useApplication.ts';
export { useAsset } from './hooks/useAsset.ts';
+export { useAssets } from './hooks/useAssets.ts';
export { useExtend } from './hooks/useExtend.ts';
+export { useSuspenseAssets } from './hooks/useSuspenseAssets.ts';
export { useTick } from './hooks/useTick.ts';
diff --git a/src/typedefs/ErrorCallback.ts b/src/typedefs/ErrorCallback.ts
new file mode 100644
index 00000000..a97e7e2d
--- /dev/null
+++ b/src/typedefs/ErrorCallback.ts
@@ -0,0 +1 @@
+export type ErrorCallback = (error: Error) => void;
diff --git a/src/typedefs/UnresolvedAsset.ts b/src/typedefs/UnresolvedAsset.ts
new file mode 100644
index 00000000..fdfa5cb1
--- /dev/null
+++ b/src/typedefs/UnresolvedAsset.ts
@@ -0,0 +1,3 @@
+import type { UnresolvedAsset as PixiUnresolvedAsset } from 'pixi.js';
+
+export type UnresolvedAsset = PixiUnresolvedAsset | string;
diff --git a/src/typedefs/UseAssetsOptions.ts b/src/typedefs/UseAssetsOptions.ts
new file mode 100644
index 00000000..dbde377c
--- /dev/null
+++ b/src/typedefs/UseAssetsOptions.ts
@@ -0,0 +1,18 @@
+import { type ErrorCallback } from './ErrorCallback';
+
+import type { ProgressCallback } from 'pixi.js';
+
+export interface UseAssetsOptions
+{
+ /** @description The maximum number of retries allowed before we give up on loading this asset. */
+ maxRetries?: number
+
+ /** @description A function to be called when if the asset loader encounters an error. */
+ onError?: ErrorCallback,
+
+ /** @description A function to be called when the asset loader reports loading progress. */
+ onProgress?: ProgressCallback,
+
+ /** @description Whether to try loading this asset again if it fails. */
+ retryOnFailure?: boolean
+}
diff --git a/src/typedefs/UseAssetsResult.ts b/src/typedefs/UseAssetsResult.ts
new file mode 100644
index 00000000..d3ca87c8
--- /dev/null
+++ b/src/typedefs/UseAssetsResult.ts
@@ -0,0 +1,22 @@
+import type { UseAssetsStatus } from '../constants/UseAssetsStatus.ts';
+
+export interface UseAssetsResult
+{
+ /** @description An array of resolved assets, or `null` for assets that are still loading. */
+ assets: (T | null)[];
+
+ /** @description The error that was encountered. */
+ error?: Error;
+
+ /** @description Whether there's an error loading these assets. */
+ isError: boolean;
+
+ /** @description Whether these assets are still loading. */
+ isPending: boolean;
+
+ /** @description Whether these assets are have successfully finished loading. */
+ isSuccess: boolean;
+
+ /** @description The current loading status of these assets. */
+ status: Lowercase;
+}
diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts
index dd6ab7fb..9b36d412 100644
--- a/test/unit/index.test.ts
+++ b/test/unit/index.test.ts
@@ -4,12 +4,15 @@ import {
it,
} from 'vitest';
import { Application } from '../../src/components/Application.ts';
+import { UseAssetsStatus } from '../../src/constants/UseAssetsStatus.ts';
import { createRoot } from '../../src/core/createRoot.ts';
import { extend } from '../../src/helpers/extend.ts';
import { useApp } from '../../src/hooks/useApp.ts';
import { useApplication } from '../../src/hooks/useApplication.ts';
import { useAsset } from '../../src/hooks/useAsset.ts';
+import { useAssets } from '../../src/hooks/useAssets.ts';
import { useExtend } from '../../src/hooks/useExtend.ts';
+import { useSuspenseAssets } from '../../src/hooks/useSuspenseAssets.ts';
import { useTick } from '../../src/hooks/useTick.ts';
import * as PixiReact from '../../src/index.ts';
@@ -51,12 +54,30 @@ describe('exports', () =>
.and.to.equal(useAsset);
});
+ it('exports the `useAssets()` hook', () =>
+ {
+ expect(PixiReact).to.have.property('useAssets')
+ .and.to.equal(useAssets);
+ });
+
+ it('exports the `UseAssetsStatus()` hook', () =>
+ {
+ expect(PixiReact).to.have.property('UseAssetsStatus')
+ .and.to.equal(UseAssetsStatus);
+ });
+
it('exports the `useExtend()` hook', () =>
{
expect(PixiReact).to.have.property('useExtend')
.and.to.equal(useExtend);
});
+ it('exports the `useSuspenseAssets()` hook', () =>
+ {
+ expect(PixiReact).to.have.property('useSuspenseAssets')
+ .and.to.equal(useSuspenseAssets);
+ });
+
it('exports the `useTick()` hook', () =>
{
expect(PixiReact).to.have.property('useTick')
@@ -72,8 +93,11 @@ describe('exports', () =>
'useApp',
'useApplication',
'useAsset',
+ 'useAssets',
+ 'UseAssetsStatus',
'useExtend',
'useTick',
+ 'useSuspenseAssets',
);
});
});