Skip to content

Commit

Permalink
Merge pull request #533 from thejustinwalsh/use-assets-bug-531
Browse files Browse the repository at this point in the history
fix: always attempt to resolve useAssets from cache  #531
  • Loading branch information
trezy authored Dec 26, 2024
2 parents 3a19cbc + e651448 commit 31ced48
Show file tree
Hide file tree
Showing 3 changed files with 107 additions and 65 deletions.
17 changes: 17 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Current Test File",
"autoAttachChildProcesses": true,
"skipFiles": ["<node_internals>/**", "**/node_modules/tinyspy/**", "**/node_modules/@vitest/**"],
"program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
"args": ["run", "${relativeFile}"],
"smartStep": true,
"console": "integratedTerminal"
}
]
}
47 changes: 26 additions & 21 deletions src/hooks/useAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ function assetsLoadedTest<T>(asset: UnresolvedAsset<T>)
return Cache.has(getAssetKey(asset));
}

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

return {
assets: assets.map((_asset: UnresolvedAsset<T>, index: number) => resolvedAssetsDictionary[index]),
isError: false,
isPending: false,
isSuccess: true,
status: UseAssetsStatus.SUCCESS,
};
}

/** Loads assets, returning a hash of assets once they're loaded. */
export function useAssets<T = any>(
/** @description Assets to be loaded. */
Expand All @@ -27,18 +41,18 @@ export function useAssets<T = any>(
options: UseAssetsOptions = {},
): UseAssetsResult<T>
{
const [state, setState] = useState<UseAssetsResult<T>>({
assets: Array(assets.length).fill(undefined),
isError: false,
isPending: true,
isSuccess: false,
status: UseAssetsStatus.PENDING,
});
const allAssetsAreLoaded = assets.every((asset) => assetsLoadedTest<T>(asset));

if (typeof window === 'undefined')
{
return state;
}
const [state, setState] = useState<UseAssetsResult<T>>(
typeof window !== 'undefined' && allAssetsAreLoaded
? resolveAssets(assets)
: {
assets: Array(assets.length).fill(undefined),
isError: false,
isPending: true,
isSuccess: false,
status: UseAssetsStatus.PENDING,
});

const {
maxRetries = 3,
Expand All @@ -47,8 +61,6 @@ export function useAssets<T = any>(
retryOnFailure = true,
} = options;

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

if (!allAssetsAreLoaded)
{
let cachedState = errorCache.get(assets);
Expand Down Expand Up @@ -80,16 +92,9 @@ export function useAssets<T = any>(
})
.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,
...resolveAssets(assets),
}));
})
.catch((error) =>
Expand Down
108 changes: 64 additions & 44 deletions test/unit/hooks/useAssets.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Assets, Cache, Sprite, Texture, type UnresolvedAsset } from 'pixi.js';
import { Assets, loadTextures, Sprite, Texture, type UnresolvedAsset } from 'pixi.js';
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { extend, useAssets } from '../../../src';
import { cleanup, renderHook, waitFor } from '@testing-library/react';
Expand All @@ -7,76 +7,58 @@ extend({ Sprite });

describe('useAssets', async () =>
{
const assets: UnresolvedAsset[] = [{ src: 'test.png' }, { src: 'test2.png' }];
loadTextures.config = {
preferWorkers: false,
preferCreateImageBitmap: true,
crossOrigin: 'anonymous',
};

// Store the loaded assets and data state to verify the hook results
let loaded: Record<string, unknown> = {};
let data: Record<string, any> = {};
const mockFetch = vi.spyOn(global, 'fetch');

// Mock the Assets.load, Assets.get & Cache.has method
const load = vi.spyOn(Assets, 'load');
const get = vi.spyOn(Assets, 'get');
const has = vi.spyOn(Cache, 'has');

// Mock the Assets.load to populate the loaded record, and resolve after 1ms
load.mockImplementation((urls) =>
global.createImageBitmap = vi.fn().mockImplementation(() => new Promise<ImageBitmap>((resolve) =>
{
const assets = urls as UnresolvedAsset[];

return new Promise((resolve) =>
{
setTimeout(() =>
{
loaded = { ...loaded, ...assets.reduce((acc, val) => ({ ...acc, [val.src!.toString()]: Texture.EMPTY }), {}) };
data = { ...data, ...assets.reduce((acc, val) => ({ ...acc, [val.src!.toString()]: val.data }), {}) };
resolve(loaded);
}, 1);
});
});

// Mock the Assets.get to return the loaded record
get.mockImplementation((keys) =>
keys.reduce<Record<string, unknown>>((acc, key, idx) => ({ ...acc, [idx]: loaded[key] }), {}));

// Mock the Cache.has to check if the key is in the loaded record
has.mockImplementation((key) => key in loaded);
setTimeout(() => resolve({ width: 100, height: 100, close: () => { /* noop */ } }), 1);
}));

// Load the default results using Assets.load to compare against the results from the useAssets hook
const defaultResults = await Assets.load<Texture>(assets);
mockFetch.mockImplementation(() => new Promise<Response>((resolve) =>
{
setTimeout(() => resolve(new Response()), 1);
}));

beforeEach(() =>
{
loaded = {};
data = {};
Assets.init({
skipDetections: true,
});
});

afterEach(() =>
{
Assets.reset();
cleanup();
});

afterAll(() =>
{
load.mockRestore();
get.mockRestore();
mockFetch.mockRestore();
});

it('loads assets', async () =>
{
const assets: UnresolvedAsset[] = [{ src: 'http://localhost/bc3d2999.png' }, { src: 'http://localhost/1a5c1ce4.png' }];

const { result } = renderHook(() => useAssets<Texture>(assets));

expect(result.current.isPending).toBe(true);
await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(result.current.assets).toEqual(assets.map(({ src }) => defaultResults[src!.toString()]));
});

it('accepts data', async () =>
{
// Explicitly type the T in the useAssets hook
const { result } = renderHook(() => useAssets<Texture>([
{ src: 'test.png', data: { test: '7a1c8bee' } },
{ src: 'test2.png', data: { test: '230a3f41' } },
{ src: 'http://localhost/7a1c8bee.png', data: { test: '7a1c8bee' } },
{ src: 'http://localhost/230a3f41.png', data: { test: '230a3f41' } },
]));

expect(result.current.isPending).toBe(true);
Expand All @@ -85,8 +67,6 @@ describe('useAssets', async () =>
const { assets: [texture], isSuccess } = result.current;

expect(isSuccess).toBe(true);
expect(data['test.png'].test).toBe('7a1c8bee');
expect(data['test2.png'].test).toBe('230a3f41');

const isTexture = (texture?: Texture) => texture && texture instanceof Texture;

Expand All @@ -97,7 +77,7 @@ describe('useAssets', async () =>
{
// Do not provide a type for T in the useAssets hook
const { result } = renderHook(() => useAssets([
{ src: 'test.png', data: { test: 'd460dbdd' } },
{ src: 'http://localhost/d460dbdd.png', data: { test: 'd460dbdd' } },
]));

expect(result.current.isPending).toBe(true);
Expand All @@ -111,4 +91,44 @@ describe('useAssets', async () =>

expect(isTexture(texture)).toBe(true);
});

it('handles subsequent loads', async () =>
{
const assets: UnresolvedAsset[] = [{ src: 'http://localhost/c13a19b0.png' }, { src: 'http://localhost/ba91270b.png' }];

const { result, rerender } = renderHook(() => useAssets<Texture>(assets));

await waitFor(() => expect(result.current.isSuccess).toBe(true));

const { result: result2, rerender: rerender2 } = renderHook(() => useAssets<Texture>(assets));

await waitFor(() => expect(result2.current.isSuccess).toBe(true));

const isTexture = (texture?: Texture) => texture && texture instanceof Texture;

expect(result.current.isSuccess).toBe(true);
expect(result2.current.isSuccess).toBe(true);
expect(isTexture(result.current.assets[0])).toBe(true);
expect(isTexture(result.current.assets[1])).toBe(true);
expect(isTexture(result2.current.assets[0])).toBe(true);
expect(isTexture(result2.current.assets[1])).toBe(true);

rerender();

expect(result.current.isSuccess).toBe(true);
expect(result2.current.isSuccess).toBe(true);
expect(isTexture(result.current.assets[0])).toBe(true);
expect(isTexture(result.current.assets[1])).toBe(true);
expect(isTexture(result2.current.assets[0])).toBe(true);
expect(isTexture(result2.current.assets[1])).toBe(true);

rerender2();

expect(result.current.isSuccess).toBe(true);
expect(result2.current.isSuccess).toBe(true);
expect(isTexture(result.current.assets[0])).toBe(true);
expect(isTexture(result.current.assets[1])).toBe(true);
expect(isTexture(result2.current.assets[0])).toBe(true);
expect(isTexture(result2.current.assets[1])).toBe(true);
});
});

0 comments on commit 31ced48

Please sign in to comment.