Skip to content

Commit 07b6933

Browse files
committed
feat: Implement generic async destruction handling via Promise Rejection (#8801)
1 parent 4efcdae commit 07b6933

4 files changed

Lines changed: 129 additions & 12 deletions

File tree

src/Neo.mjs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,20 @@ Neo = globalThis.Neo = Object.assign({
8787
*/
8888
ntypeMap: {},
8989
/**
90-
* Needed for Neo.create. False for the main thread, true for the App, Data & Vdom worker
90+
* Needed for Neo.create. False for the main thread, true for the App, Data & VDom worker
9191
* @memberOf! module:Neo
9292
* @protected
9393
* @type Boolean
9494
*/
9595
insideWorker: typeof DedicatedWorkerGlobalScope !== 'undefined' || typeof WorkerGlobalScope !== 'undefined',
9696

97+
/**
98+
* A symbol to identify if a promise was rejected because the instance got destroyed.
99+
* @memberOf! module:Neo
100+
* @type {Symbol}
101+
*/
102+
isDestroyed: Symbol.for('Neo.isDestroyed'),
103+
97104
/**
98105
* Maps methods from one namespace to another one
99106
* @example
@@ -1172,4 +1179,12 @@ Neo.config ??= {};
11721179

11731180
Neo.assignDefaults(Neo.config, DefaultConfig);
11741181

1182+
if (typeof globalThis.addEventListener === 'function') {
1183+
globalThis.addEventListener('unhandledrejection', e => {
1184+
if (e.reason === Neo.isDestroyed) {
1185+
e.preventDefault()
1186+
}
1187+
})
1188+
}
1189+
11751190
export default Neo;

src/component/MagicMoveText.mjs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,8 @@ class MagicMoveText extends Component {
344344

345345
await me.updateChars()
346346
} catch (e) {
347+
if (e === Neo.isDestroyed) return;
348+
347349
if (!me.isRetrying) {
348350
me.isRetrying = true;
349351
me.previousChars = [];
@@ -458,7 +460,6 @@ class MagicMoveText extends Component {
458460
await me.promiseUpdate();
459461
if (me.isDestroyed) return;
460462
await me.timeout(20);
461-
if (me.isDestroyed) return;
462463

463464
rects = await me.getDomRect([me.id, measureWrapper.id, ...measureElement.cn.map(node => node.id)]);
464465
if (me.isDestroyed) return;
@@ -640,7 +641,6 @@ class MagicMoveText extends Component {
640641
await me.promiseUpdate();
641642
if (me.isDestroyed) return;
642643
await me.timeout(me.transitionTime);
643-
if (me.isDestroyed) return;
644644

645645
charsContainer.cn.sort(me.sortCharacters);
646646

@@ -660,7 +660,6 @@ class MagicMoveText extends Component {
660660
await me.promiseUpdate();
661661
if (me.isDestroyed) return;
662662
await me.timeout(200);
663-
if (me.isDestroyed) return;
664663

665664
me.charsVdom = [...charsContainer.cn];
666665

src/core/Base.mjs

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -207,10 +207,10 @@ class Base {
207207
#remotesReadyResolver = null;
208208
/**
209209
* Internal cache for all timeout ids when using this.timeout()
210-
* @member {Number[]} timeoutIds=[]
210+
* @member {Map<Number, Function>} #timeouts=new Map()
211211
* @private
212212
*/
213-
#timeoutIds = []
213+
#timeouts = new Map()
214214

215215
/**
216216
* The main initializer for all Neo.mjs classes, invoked by `Neo.create()`.
@@ -510,10 +510,13 @@ class Base {
510510
destroy() {
511511
let me = this;
512512

513-
me.#timeoutIds.forEach(id => {
514-
clearTimeout(id)
513+
me.#timeouts.forEach((reject, id) => {
514+
clearTimeout(id);
515+
reject(Neo.isDestroyed)
515516
});
516517

518+
me.#timeouts.clear();
519+
517520
me.#configSubscriptionCleanups.forEach(cleanup => {
518521
cleanup()
519522
});
@@ -1086,11 +1089,15 @@ class Base {
10861089
* @returns {Promise<any>}
10871090
*/
10881091
timeout(time) {
1089-
return new Promise(resolve => {
1090-
let timeoutIds = this.#timeoutIds,
1091-
timeoutId = setTimeout(() => {timeoutIds.splice(timeoutIds.indexOf(timeoutId), 1); resolve()}, time);
1092+
let me = this;
1093+
1094+
return new Promise((resolve, reject) => {
1095+
let id = setTimeout(() => {
1096+
me.#timeouts.delete(id);
1097+
resolve()
1098+
}, time);
10921099

1093-
timeoutIds.push(timeoutId)
1100+
me.#timeouts.set(id, reject)
10941101
})
10951102
}
10961103

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {test, expect} from '@playwright/test';
2+
import Neo from '../../../../src/Neo.mjs';
3+
import Base from '../../../../src/core/Base.mjs';
4+
5+
test.describe('core/Base Timeout Handling', () => {
6+
test('timeout() should resolve after the specified delay', async () => {
7+
class TestClass extends Base {
8+
static config = {
9+
className: 'Neo.test.TimeoutTestClass'
10+
}
11+
}
12+
Neo.setupClass(TestClass);
13+
14+
const instance = Neo.create(TestClass);
15+
const start = Date.now();
16+
await instance.timeout(100);
17+
const end = Date.now();
18+
19+
expect(end - start).toBeGreaterThanOrEqual(95); // Allow small margin
20+
});
21+
22+
test('timeout() should be rejected with Neo.isDestroyed when instance is destroyed', async () => {
23+
class TestClass extends Base {
24+
static config = {
25+
className: 'Neo.test.TimeoutDestroyTestClass'
26+
}
27+
}
28+
Neo.setupClass(TestClass);
29+
30+
const instance = Neo.create(TestClass);
31+
let error;
32+
33+
// Start timeout but don't await immediately to allow destruction
34+
const timeoutPromise = instance.timeout(500);
35+
36+
// Destroy instance before timeout completes
37+
instance.destroy();
38+
39+
try {
40+
await timeoutPromise;
41+
} catch (e) {
42+
error = e;
43+
}
44+
45+
expect(error).toBe(Neo.isDestroyed);
46+
});
47+
48+
test('Multiple timeouts should be handled correctly', async () => {
49+
class TestClass extends Base {
50+
static config = {
51+
className: 'Neo.test.MultipleTimeoutTestClass'
52+
}
53+
}
54+
Neo.setupClass(TestClass);
55+
56+
const instance = Neo.create(TestClass);
57+
let error1, error2;
58+
59+
const p1 = instance.timeout(200);
60+
const p2 = instance.timeout(400);
61+
62+
instance.destroy();
63+
64+
try {
65+
await p1;
66+
} catch (e) {
67+
error1 = e;
68+
}
69+
70+
try {
71+
await p2;
72+
} catch (e) {
73+
error2 = e;
74+
}
75+
76+
expect(error1).toBe(Neo.isDestroyed);
77+
expect(error2).toBe(Neo.isDestroyed);
78+
});
79+
80+
test('Completed timeouts should not prevent destruction or throw errors', async () => {
81+
class TestClass extends Base {
82+
static config = {
83+
className: 'Neo.test.CompletedTimeoutTestClass'
84+
}
85+
}
86+
Neo.setupClass(TestClass);
87+
88+
const instance = Neo.create(TestClass);
89+
90+
await instance.timeout(50); // let it finish
91+
92+
// Should not throw
93+
instance.destroy();
94+
expect(instance.isDestroyed).toBe(true);
95+
});
96+
});

0 commit comments

Comments
 (0)