diff --git a/CHANGELOG.md b/CHANGELOG.md index 50edfb4df0..5469ba0425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed +- Fixed issue where `Resource.load()` would hang forever on network errors (DNS failure, CORS block, offline), deadlocking the loader and all scene navigation. The promise now rejects with a `ResourceLoadingError` containing the resource path and a descriptive message +- Fixed issue where `Gif.isLoaded()` always returned `true` because it checked `!!this.data` on an empty array, causing Gifs to be silently skipped by the loader and never parsed - Fixed issue where `scaleTo({…})` and `scaleBy({…})` actions would cause entities to keep scaling indefinitely after the action completed, due to a copy-paste bug that zeroed `angularVelocity` instead of `scaleFactor` - Fixed issue where `scaleTo({…})` and `scaleBy({…})` actions used a live reference to the entity's scale vector as the interpolation start point, causing the easing curve to be corrupted if the entity's scale changed during the action - Fixed issue where the first action in a sequence would not execute after calling `clearActions()` mid-execution. All action types now properly reset their initialization state when stopped, resolving issue #3468 diff --git a/src/engine/resources/gif.ts b/src/engine/resources/gif.ts index 213fcfbdc2..99cfae751c 100644 --- a/src/engine/resources/gif.ts +++ b/src/engine/resources/gif.ts @@ -71,7 +71,7 @@ export class Gif implements Loadable { } public isLoaded() { - return !!this.data; + return this.data.length > 0; } /** diff --git a/src/engine/resources/resource.ts b/src/engine/resources/resource.ts index 3ae0c882cb..9ee50ff48d 100644 --- a/src/engine/resources/resource.ts +++ b/src/engine/resources/resource.ts @@ -18,6 +18,20 @@ export const ResourceEvents = { Error: 'error' }; +/** + * Error thrown when a resource fails to load due to network issues, + * CORS problems, or the resource being unreachable. + */ +export class ResourceLoadingError extends Error { + constructor( + public path: string, + message: string + ) { + super(message); + this.name = 'ResourceLoadingError'; + } +} + /** * The {@apilink Resource} type allows games built in Excalibur to load generic resources. * For any type of remote resource it is recommended to use {@apilink Resource} for preloading. @@ -73,7 +87,23 @@ export class Resource implements Loadable { request.responseType = this.responseType; request.addEventListener('loadstart', (e) => this.events.emit('loadstart', e as any)); request.addEventListener('progress', (e) => this.events.emit('progress', e as any)); - request.addEventListener('error', (e) => this.events.emit('error', e as any)); + request.addEventListener('error', (e) => { + this.events.emit('error', e as any); + reject( + new ResourceLoadingError( + this.path, + `Failed to load resource at ${this.path}. This may be a network error, CORS issue, or the resource is unreachable.` + ) + ); + }); + request.addEventListener('abort', (e) => { + this.events.emit('error', e as any); + reject(new ResourceLoadingError(this.path, `Request to load resource at ${this.path} was aborted.`)); + }); + request.addEventListener('timeout', (e) => { + this.events.emit('error', e as any); + reject(new ResourceLoadingError(this.path, `Request to load resource at ${this.path} timed out.`)); + }); request.addEventListener('load', (e) => this.events.emit('load', e as any)); request.addEventListener('load', () => { // XHR on file:// success status is 0, such as with PhantomJS diff --git a/src/spec/vitest/gif-spec.ts b/src/spec/vitest/gif-spec.ts index c2f22449c9..f79da48b4e 100644 --- a/src/spec/vitest/gif-spec.ts +++ b/src/spec/vitest/gif-spec.ts @@ -18,6 +18,16 @@ describe('A Gif', () => { engine = null; }); + it('should not be loaded by default (C6 regression)', () => { + expect(gif.isLoaded()).toBe(false); + }); + + it('should be loaded after load() completes (C6 regression)', async () => { + expect(gif.isLoaded()).toBe(false); + await gif.load(); + expect(gif.isLoaded()).toBe(true); + }); + it('should parse gif files correctly', () => new Promise((done) => { gif.load().then(() => { diff --git a/src/spec/vitest/resource-spec.ts b/src/spec/vitest/resource-spec.ts index 253124baa2..711de8095a 100644 --- a/src/spec/vitest/resource-spec.ts +++ b/src/spec/vitest/resource-spec.ts @@ -74,4 +74,22 @@ describe('A generic Resource', () => { }); })); }); + + describe('on network error', () => { + it('should reject with ResourceLoadingError on XHR error', () => + new Promise((done) => { + // Use a path that will trigger a network error (not a 404, but an actual XHR error) + // Using an invalid protocol triggers the XHR 'error' event + const badResource = new ex.Resource('file:///nonexistent/path/resource.png', 'blob'); + badResource.load().then( + () => fail('Expected rejection'), + (err) => { + expect(err).toBeInstanceOf(ex.ResourceLoadingError); + expect(err.path).toBe('file:///nonexistent/path/resource.png'); + expect(err.message).toContain('Failed to load resource'); + done(); + } + ); + })); + }); });