diff --git a/.yarn/versions/a6d19319.yml b/.yarn/versions/a6d19319.yml new file mode 100644 index 00000000..d3ace379 --- /dev/null +++ b/.yarn/versions/a6d19319.yml @@ -0,0 +1,4 @@ +releases: + miew: patch + miew-app: patch + miew-react: patch diff --git a/.yarnrc.yml b/.yarnrc.yml index 5b9c141e..7c48baed 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -11,3 +11,6 @@ plugins: spec: "@yarnpkg/plugin-version" yarnPath: .yarn/releases/yarn-3.6.3.cjs + +changesetBaseRefs: + - "origin/main" diff --git a/packages/miew/CHANGELOG.md b/packages/miew/CHANGELOG.md index a2db9e96..a620c0fe 100644 --- a/packages/miew/CHANGELOG.md +++ b/packages/miew/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Fixed + +- Fix uncaught promise rejection when a load operation is cancelled. The `load()` promise now + resolves normally on cancellation instead of rejecting with an unhandled error. + ## [0.12.0] - 2026-06-09 ### Changed diff --git a/packages/miew/src/Miew.js b/packages/miew/src/Miew.js index c399f39d..d46ac620 100644 --- a/packages/miew/src/Miew.js +++ b/packages/miew/src/Miew.js @@ -12,6 +12,7 @@ import Visual from './Visual'; import ComplexVisual from './ComplexVisual'; import Complex from './chem/Complex'; import VolumeVisual from './VolumeVisual'; +import CancellationError from './io/CancellationError'; import io from './io/io'; import modes from './gfx/modes'; import colorers from './gfx/colorers'; @@ -1831,9 +1832,10 @@ function updateBinaryMode(opts) { } function _fetchData(source, opts, job) { - return new Promise(((resolve) => { + return new Promise(((resolve, reject) => { if (job.shouldCancel()) { - throw new Error('Operation cancelled'); + reject(new CancellationError('Operation cancelled')); + return; } job.notify({ type: 'fetching' }); @@ -1843,7 +1845,8 @@ function _fetchData(source, opts, job) { // detect a proper loader const TheLoader = _.head(io.loaders.find({ type: opts.sourceType, source })); if (!TheLoader) { - throw new Error(LOADER_NOT_FOUND); + reject(new Error(LOADER_NOT_FOUND)); + return; } // split file name @@ -1898,11 +1901,13 @@ function _fetchData(source, opts, job) { .catch((error) => { console.timeEnd('fetch'); opts.context.logger.debug(error.message); - if (error.stack) { - opts.context.logger.debug(error.stack); + if (!(error instanceof CancellationError)) { + if (error.stack) { + opts.context.logger.debug(error.stack); + } + opts.context.logger.error('Fetching failed'); + job.notify({ type: 'fetchingDone', error }); } - opts.context.logger.error('Fetching failed'); - job.notify({ type: 'fetchingDone', error }); throw error; }); resolve(promise); @@ -1911,7 +1916,7 @@ function _fetchData(source, opts, job) { function _parseData(data, opts, job) { if (job.shouldCancel()) { - return Promise.reject(new Error('Operation cancelled')); + return Promise.reject(new CancellationError('Operation cancelled')); } job.notify({ type: 'parsing' }); @@ -1936,11 +1941,13 @@ function _parseData(data, opts, job) { console.timeEnd('parse'); opts.error = error; opts.context.logger.debug(error.message); - if (error.stack) { - opts.context.logger.debug(error.stack); + if (!(error instanceof CancellationError)) { + if (error.stack) { + opts.context.logger.debug(error.stack); + } + opts.context.logger.error('Parsing failed'); + job.notify({ type: 'parsingDone', error }); } - opts.context.logger.error('Parsing failed'); - job.notify({ type: 'parsingDone', error }); throw error; }); } @@ -2006,6 +2013,10 @@ Miew.prototype.load = function (source, opts) { return onLoadEnd(name); }) .catch((err) => { + if (err instanceof CancellationError) { + this.logger.debug('Loading cancelled'); + return onLoadEnd(undefined); + } this.logger.error('Could not load data'); this.logger.debug(err); throw onLoadEnd(err); diff --git a/packages/miew/src/io/CancellationError.js b/packages/miew/src/io/CancellationError.js new file mode 100644 index 00000000..06aefb08 --- /dev/null +++ b/packages/miew/src/io/CancellationError.js @@ -0,0 +1,7 @@ +export default class CancellationError extends Error { + constructor(message) { + super(message); + this.name = 'CancellationError'; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/packages/miew/src/io/CancellationError.test.js b/packages/miew/src/io/CancellationError.test.js new file mode 100644 index 00000000..227e15f7 --- /dev/null +++ b/packages/miew/src/io/CancellationError.test.js @@ -0,0 +1,31 @@ +import chai, { expect } from 'chai'; +import dirtyChai from 'dirty-chai'; +import CancellationError from './CancellationError'; + +chai.use(dirtyChai); + +describe('CancellationError', () => { + describe('constructor', () => { + let error; + + beforeEach(() => { + error = new CancellationError('foo'); + }); + + it('creates an Error', () => { + expect(error).to.be.an('error'); + }); + + it('creates a throwable instance', () => { + expect(() => { throw error; }).to.throw(CancellationError); + }); + + it('sets correct name', () => { + expect(error).to.have.property('name', 'CancellationError'); + }); + + it('sets correct message', () => { + expect(error).to.have.property('message', 'foo'); + }); + }); +}); diff --git a/packages/miew/src/io/io.js b/packages/miew/src/io/io.js index 1686c099..6bf9c98c 100644 --- a/packages/miew/src/io/io.js +++ b/packages/miew/src/io/io.js @@ -1,8 +1,10 @@ +import CancellationError from './CancellationError'; import loaders from './loaders'; import parsers from './parsers'; import exporters from './exporters'; export default { + CancellationError, loaders, parsers, exporters, diff --git a/packages/miew/src/io/loaders/FileLoader.js b/packages/miew/src/io/loaders/FileLoader.js index d2f566c6..e8fda2f6 100644 --- a/packages/miew/src/io/loaders/FileLoader.js +++ b/packages/miew/src/io/loaders/FileLoader.js @@ -1,3 +1,4 @@ +import CancellationError from '../CancellationError'; import Loader from './Loader'; export default class FileLoader extends Loader { @@ -11,7 +12,8 @@ export default class FileLoader extends Loader { load() { return new Promise((resolve, reject) => { if (this._abort) { - throw new Error('Loading aborted'); + reject(new CancellationError('Loading aborted')); + return; } const blob = this._source; @@ -24,7 +26,7 @@ export default class FileLoader extends Loader { reject(reader.error); }); reader.addEventListener('abort', () => { - reject(new Error('Loading aborted')); + reject(new CancellationError('Loading aborted')); }); reader.addEventListener('progress', (event) => { this.dispatchEvent(event); diff --git a/packages/miew/src/io/loaders/FileLoader.test.js b/packages/miew/src/io/loaders/FileLoader.test.js index 2c0dbf98..4f00b4b9 100644 --- a/packages/miew/src/io/loaders/FileLoader.test.js +++ b/packages/miew/src/io/loaders/FileLoader.test.js @@ -1,6 +1,7 @@ import chai, { expect } from 'chai'; import dirtyChai from 'dirty-chai'; import sinon from 'sinon'; +import CancellationError from '../CancellationError'; import FileLoader from './FileLoader'; chai.use(dirtyChai); @@ -117,12 +118,27 @@ describe('FileLoader', () => { return expect(loader.load()).to.be.rejected(); }); + it('rejects with CancellationError if aborted beforehand', () => { + loader.abort(); + return expect(loader.load()).to.be.rejected().then((err) => { + expect(err).to.be.instanceOf(CancellationError); + }); + }); + it('rejects a promise if aborted afterwards', () => { const promise = loader.load(); loader.abort(); return expect(promise).to.be.rejected(); }); + it('rejects with CancellationError if aborted afterwards', () => { + const promise = loader.load(); + loader.abort(); + return expect(promise).to.be.rejected().then((err) => { + expect(err).to.be.instanceOf(CancellationError); + }); + }); + it('generates progress events', () => { const onProgress = sinon.spy(); loader.addEventListener('progress', onProgress); diff --git a/packages/miew/src/io/loaders/XHRLoader.js b/packages/miew/src/io/loaders/XHRLoader.js index 1a528973..01fb27bb 100644 --- a/packages/miew/src/io/loaders/XHRLoader.js +++ b/packages/miew/src/io/loaders/XHRLoader.js @@ -1,4 +1,5 @@ import _ from 'lodash'; +import CancellationError from '../CancellationError'; import Loader from './Loader'; // we don't need to detect all kinds of URLs, just the evident ones @@ -15,7 +16,8 @@ export default class XHRLoader extends Loader { load() { return new Promise((resolve, reject) => { if (this._abort) { - throw new Error('Loading aborted'); + reject(new CancellationError('Loading aborted')); + return; } const url = this._source; @@ -32,7 +34,7 @@ export default class XHRLoader extends Loader { reject(new Error('HTTP request failed')); }); request.addEventListener('abort', () => { - reject(new Error('Loading aborted')); + reject(new CancellationError('Loading aborted')); }); request.addEventListener('progress', (event) => { this.dispatchEvent(event); diff --git a/packages/miew/src/io/loaders/XHRLoader.test.js b/packages/miew/src/io/loaders/XHRLoader.test.js index e1edcb73..514fb324 100644 --- a/packages/miew/src/io/loaders/XHRLoader.test.js +++ b/packages/miew/src/io/loaders/XHRLoader.test.js @@ -1,6 +1,7 @@ import chai, { expect } from 'chai'; import dirtyChai from 'dirty-chai'; import sinon from 'sinon'; +import CancellationError from '../CancellationError'; import XHRLoader from './XHRLoader'; chai.use(dirtyChai); @@ -115,12 +116,27 @@ describe('XHRLoader', () => { return expect(loader.load()).to.be.rejected(); }); + it('rejects with CancellationError if aborted beforehand', () => { + loader.abort(); + return expect(loader.load()).to.be.rejected().then((err) => { + expect(err).to.be.instanceOf(CancellationError); + }); + }); + it('rejects a promise if aborted afterwards', () => { const promise = loader.load(); loader.abort(); return expect(promise).to.be.rejected(); }); + it('rejects with CancellationError if aborted afterwards', () => { + const promise = loader.load(); + loader.abort(); + return expect(promise).to.be.rejected().then((err) => { + expect(err).to.be.instanceOf(CancellationError); + }); + }); + it('generates progress events', () => { const onProgress = sinon.spy(); loader.addEventListener('progress', onProgress); diff --git a/packages/miew/src/io/parsers/Parser.js b/packages/miew/src/io/parsers/Parser.js index b91e7605..ba3eeb68 100644 --- a/packages/miew/src/io/parsers/Parser.js +++ b/packages/miew/src/io/parsers/Parser.js @@ -1,3 +1,4 @@ +import CancellationError from '../CancellationError'; import makeContextDependent from '../../utils/makeContextDependent'; export default class Parser { @@ -16,7 +17,7 @@ export default class Parser { setTimeout(() => { try { if (this._abort) { - return reject(new Error('Parsing aborted')); + return reject(new CancellationError('Parsing aborted')); } return resolve(this.parseSync()); } catch (error) { diff --git a/packages/miew/src/io/parsers/Parser.test.js b/packages/miew/src/io/parsers/Parser.test.js index 204a281c..845496bd 100644 --- a/packages/miew/src/io/parsers/Parser.test.js +++ b/packages/miew/src/io/parsers/Parser.test.js @@ -2,6 +2,7 @@ import chai, { expect } from 'chai'; import dirtyChai from 'dirty-chai'; import sinon from 'sinon'; import chaiAsPromised from 'chai-as-promised'; +import CancellationError from '../CancellationError'; import Parser from './Parser'; chai.use(dirtyChai); @@ -50,5 +51,20 @@ describe('Parser', () => { expect(parser.parseSync).to.not.have.been.called(); }); }); + + it('rejects with CancellationError if aborted beforehand', () => { + parser.abort(); + return expect(parser.parse()).to.be.rejected().then((err) => { + expect(err).to.be.instanceOf(CancellationError); + }); + }); + + it('rejects with CancellationError if aborted afterwards', () => { + const promise = parser.parse(); + parser.abort(); + return expect(promise).to.be.rejected().then((err) => { + expect(err).to.be.instanceOf(CancellationError); + }); + }); }); }); diff --git a/packages/miew/src/io/parsers/ParsingError.js b/packages/miew/src/io/parsers/ParsingError.js index c117666b..39f2272c 100644 --- a/packages/miew/src/io/parsers/ParsingError.js +++ b/packages/miew/src/io/parsers/ParsingError.js @@ -2,13 +2,10 @@ class ParsingError extends Error { constructor(message, line, column) { super(`data:${line}:${column}: ${message}`); - if (Error.captureStackTrace) { - Error.captureStackTrace(this, ParsingError); - } - this.name = 'ParsingError'; this.parseLine = line; this.parseColumn = column; + Object.setPrototypeOf(this, new.target.prototype); } } diff --git a/packages/miew/src/io/parsers/ParsingError.test.js b/packages/miew/src/io/parsers/ParsingError.test.js index 380c3766..91fca48d 100644 --- a/packages/miew/src/io/parsers/ParsingError.test.js +++ b/packages/miew/src/io/parsers/ParsingError.test.js @@ -18,7 +18,7 @@ describe('ParsingError', () => { expect(error).to.be.an('error'); }); - it('creates an throwable instance', () => { + it('creates a throwable instance', () => { expect(() => { throw error; }).to.throw(ParsingError); }); diff --git a/packages/miew/src/utils.js b/packages/miew/src/utils.js index 83ff6292..a76d1a26 100644 --- a/packages/miew/src/utils.js +++ b/packages/miew/src/utils.js @@ -241,9 +241,9 @@ DebugTracer.spaces = ' class OutOfMemoryError extends Error { constructor(message) { - super(); + super(message); this.name = 'OutOfMemoryError'; - this.message = message; + Object.setPrototypeOf(this, new.target.prototype); } } diff --git a/packages/miew/src/utils.test.js b/packages/miew/src/utils.test.js index 5d496bf4..f5735828 100644 --- a/packages/miew/src/utils.test.js +++ b/packages/miew/src/utils.test.js @@ -3,6 +3,8 @@ import dirtyChai from 'dirty-chai'; import _ from 'lodash'; import utils from './utils'; +const { OutOfMemoryError } = utils; + chai.use(dirtyChai); describe('utils.deriveDeep()', () => { @@ -219,3 +221,31 @@ describe('utils.objectsDiff()', () => { expectSame({ date: 5 }); }); }); + +describe('utils.OutOfMemoryError', () => { + let error; + + beforeEach(() => { + error = new OutOfMemoryError('out of memory'); + }); + + it('creates an Error', () => { + expect(error).to.be.an('error'); + }); + + it('creates a throwable instance', () => { + expect(() => { throw error; }).to.throw(OutOfMemoryError); + }); + + it('is an instanceof OutOfMemoryError', () => { + expect(error).to.be.instanceOf(OutOfMemoryError); + }); + + it('sets correct name', () => { + expect(error).to.have.property('name', 'OutOfMemoryError'); + }); + + it('sets correct message', () => { + expect(error).to.have.property('message', 'out of memory'); + }); +});