Skip to content

Commit 16b4ec8

Browse files
MiodecCopilot
andauthored
impr: add reset function to promiseWithResolvers (@Miodec) (monkeytypegame#7280)
Actual AI slop but if it works ????? --------- Co-authored-by: Copilot <[email protected]>
1 parent ef5ef0c commit 16b4ec8

File tree

4 files changed

+304
-14
lines changed

4 files changed

+304
-14
lines changed

frontend/__tests__/utils/misc.spec.ts

Lines changed: 234 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1-
import { describe, it, expect } from "vitest";
2-
import { getErrorMessage, isObject, escapeHTML } from "../../src/ts/utils/misc";
1+
import { describe, it, expect, vi } from "vitest";
2+
import {
3+
getErrorMessage,
4+
isObject,
5+
escapeHTML,
6+
promiseWithResolvers,
7+
} from "../../src/ts/utils/misc";
38
import {
49
getLanguageDisplayString,
510
removeLanguageSize,
@@ -218,4 +223,231 @@ describe("misc.ts", () => {
218223
});
219224
});
220225
});
226+
227+
describe("promiseWithResolvers", () => {
228+
it("should resolve the promise from outside", async () => {
229+
//GIVEN
230+
const { promise, resolve } = promiseWithResolvers<number>();
231+
232+
//WHEN
233+
resolve(42);
234+
235+
//THEN
236+
await expect(promise).resolves.toBe(42);
237+
});
238+
239+
it("should resolve new promise after reset using same promise reference", async () => {
240+
const { promise, resolve, reset } = promiseWithResolvers<number>();
241+
const firstPromise = promise;
242+
243+
reset();
244+
245+
resolve(10);
246+
247+
await expect(firstPromise).resolves.toBe(10);
248+
expect(promise).toBe(firstPromise);
249+
});
250+
251+
it("should reject the promise from outside", async () => {
252+
//GIVEN
253+
const { promise, reject } = promiseWithResolvers<number>();
254+
const error = new Error("test error");
255+
256+
//WHEN
257+
reject(error);
258+
259+
//THEN
260+
await expect(promise).rejects.toThrow("test error");
261+
});
262+
263+
it("should work with void type", async () => {
264+
//GIVEN
265+
const { promise, resolve } = promiseWithResolvers();
266+
267+
//WHEN
268+
resolve();
269+
270+
//THEN
271+
await expect(promise).resolves.toBeUndefined();
272+
});
273+
274+
it("should allow multiple resolves (only first takes effect)", async () => {
275+
//GIVEN
276+
const { promise, resolve } = promiseWithResolvers<number>();
277+
278+
//WHEN
279+
resolve(42);
280+
resolve(100); // This should have no effect
281+
282+
//THEN
283+
await expect(promise).resolves.toBe(42);
284+
});
285+
286+
it("should reset and create a new promise", async () => {
287+
//GIVEN
288+
const { promise, resolve, reset } = promiseWithResolvers<number>();
289+
resolve(42);
290+
291+
//WHEN
292+
reset();
293+
resolve(100);
294+
295+
//THEN
296+
await expect(promise).resolves.toBe(100);
297+
});
298+
299+
it("should keep the same promise reference after reset", async () => {
300+
//GIVEN
301+
const wrapper = promiseWithResolvers<number>();
302+
const firstPromise = wrapper.promise;
303+
wrapper.resolve(42);
304+
await expect(firstPromise).resolves.toBe(42);
305+
306+
//WHEN
307+
wrapper.reset();
308+
const secondPromise = wrapper.promise;
309+
wrapper.resolve(100);
310+
311+
//THEN
312+
expect(firstPromise).toBe(secondPromise); // Same reference
313+
await expect(wrapper.promise).resolves.toBe(100);
314+
});
315+
316+
it("should allow reject after reset", async () => {
317+
//GIVEN
318+
const wrapper = promiseWithResolvers<number>();
319+
wrapper.resolve(42);
320+
await wrapper.promise;
321+
322+
//WHEN
323+
wrapper.reset();
324+
const error = new Error("after reset");
325+
wrapper.reject(error);
326+
327+
//THEN
328+
await expect(wrapper.promise).rejects.toThrow("after reset");
329+
});
330+
331+
it("should work with complex types", async () => {
332+
//GIVEN
333+
type ComplexType = { id: number; data: string[] };
334+
const { promise, resolve } = promiseWithResolvers<ComplexType>();
335+
const data: ComplexType = { id: 1, data: ["a", "b", "c"] };
336+
337+
//WHEN
338+
resolve(data);
339+
340+
//THEN
341+
await expect(promise).resolves.toEqual(data);
342+
});
343+
344+
it("should handle rejection with non-Error values", async () => {
345+
//GIVEN
346+
const { promise, reject } = promiseWithResolvers<number>();
347+
348+
//WHEN
349+
reject("string error");
350+
351+
//THEN
352+
await expect(promise).rejects.toBe("string error");
353+
});
354+
355+
it("should allow chaining with then/catch", async () => {
356+
//GIVEN
357+
const { promise, resolve } = promiseWithResolvers<number>();
358+
const onFulfilled = vi.fn((value) => value * 2);
359+
const chained = promise.then(onFulfilled);
360+
361+
//WHEN
362+
resolve(21);
363+
364+
//THEN
365+
await expect(chained).resolves.toBe(42);
366+
expect(onFulfilled).toHaveBeenCalledWith(21);
367+
});
368+
369+
it("should support async/await patterns", async () => {
370+
//GIVEN
371+
const { promise, resolve } = promiseWithResolvers<string>();
372+
373+
//WHEN
374+
setTimeout(() => resolve("delayed"), 10);
375+
376+
//THEN
377+
const result = await promise;
378+
expect(result).toBe("delayed");
379+
});
380+
381+
it("should resolve old promise reference after reset", async () => {
382+
//GIVEN
383+
const wrapper = promiseWithResolvers<number>();
384+
const oldPromise = wrapper.promise;
385+
386+
//WHEN
387+
wrapper.reset();
388+
wrapper.resolve(42);
389+
390+
//THEN
391+
// Old promise reference should still resolve with the same value
392+
await expect(oldPromise).resolves.toBe(42);
393+
expect(oldPromise).toBe(wrapper.promise);
394+
});
395+
396+
it("should handle catch", async () => {
397+
//GIVEN
398+
const { promise, reject } = promiseWithResolvers<number>();
399+
const error = new Error("test error");
400+
401+
//WHEN
402+
const caught = promise.catch(() => "recovered");
403+
reject(error);
404+
405+
//THEN
406+
await expect(caught).resolves.toBe("recovered");
407+
});
408+
409+
it("should call finally handler on resolution", async () => {
410+
//GIVEN
411+
const { promise, resolve } = promiseWithResolvers<number>();
412+
const onFinally = vi.fn();
413+
414+
//WHEN
415+
const final = promise.finally(onFinally);
416+
resolve(42);
417+
418+
//THEN
419+
await expect(final).resolves.toBe(42);
420+
expect(onFinally).toHaveBeenCalledOnce();
421+
});
422+
423+
it("should call finally handler on rejection", async () => {
424+
//GIVEN
425+
const { promise, reject } = promiseWithResolvers<number>();
426+
const onFinally = vi.fn();
427+
const error = new Error("test error");
428+
429+
//WHEN
430+
const final = promise.finally(onFinally);
431+
reject(error);
432+
433+
//THEN
434+
await expect(final).rejects.toThrow("test error");
435+
expect(onFinally).toHaveBeenCalledOnce();
436+
});
437+
438+
it("should preserve rejection through finally", async () => {
439+
//GIVEN
440+
const { promise, reject } = promiseWithResolvers<number>();
441+
const onFinally = vi.fn();
442+
const error = new Error("preserved error");
443+
444+
//WHEN
445+
const final = promise.finally(onFinally);
446+
reject(error);
447+
448+
//THEN
449+
await expect(final).rejects.toThrow("preserved error");
450+
expect(onFinally).toHaveBeenCalled();
451+
});
452+
});
221453
});

frontend/src/ts/modals/register-captcha.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import AnimatedModal from "../utils/animated-modal";
33
import { promiseWithResolvers } from "../utils/misc";
44
import * as Notifications from "../elements/notifications";
55

6-
let { promise, resolve } = promiseWithResolvers<string | undefined>();
6+
const {
7+
promise,
8+
resolve,
9+
reset: resetPromise,
10+
} = promiseWithResolvers<string | undefined>();
711

812
export { promise };
913

@@ -20,7 +24,7 @@ export async function show(): Promise<void> {
2024
await modal.show({
2125
mode: "dialog",
2226
beforeAnimation: async (modal) => {
23-
({ promise, resolve } = promiseWithResolvers<string | undefined>());
27+
resetPromise();
2428
CaptchaController.reset("register");
2529

2630
CaptchaController.render(

frontend/src/ts/test/test-state.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,18 @@ export function setIsDirectionReversed(val: boolean): void {
6767
isDirectionReversed = val;
6868
}
6969

70-
let { promise: testRestartingPromise, resolve: restartingResolve } =
71-
promiseWithResolvers();
70+
const {
71+
promise: testRestartingPromise,
72+
resolve: restartingResolve,
73+
reset: resetTestRestarting,
74+
} = promiseWithResolvers();
7275

7376
export { testRestartingPromise };
7477

7578
export function setTestRestarting(val: boolean): void {
7679
testRestarting = val;
7780
if (val) {
78-
({ promise: testRestartingPromise, resolve: restartingResolve } =
79-
promiseWithResolvers());
81+
resetTestRestarting();
8082
} else {
8183
restartingResolve();
8284
}

frontend/src/ts/utils/misc.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -597,19 +597,71 @@ export function applyReducedMotion(animationTime: number): number {
597597
/**
598598
* Creates a promise with resolvers.
599599
* This is useful for creating a promise that can be resolved or rejected from outside the promise itself.
600+
* The returned promise reference stays constant even after reset() - it will always await the current internal promise.
601+
* Note: Promise chains created via .then()/.catch()/.finally() will always follow the current internal promise state, even if created before reset().
600602
*/
601603
export function promiseWithResolvers<T = void>(): {
602604
resolve: (value: T) => void;
603605
reject: (reason?: unknown) => void;
604606
promise: Promise<T>;
607+
reset: () => void;
605608
} {
606-
let resolve!: (value: T) => void;
607-
let reject!: (reason?: unknown) => void;
608-
const promise = new Promise<T>((res, rej) => {
609-
resolve = res;
610-
reject = rej;
609+
let innerResolve!: (value: T) => void;
610+
let innerReject!: (reason?: unknown) => void;
611+
let currentPromise = new Promise<T>((res, rej) => {
612+
innerResolve = res;
613+
innerReject = rej;
611614
});
612-
return { resolve, reject, promise };
615+
616+
/**
617+
* This was fully AI generated to make the reset function work. Black magic, but its unit-tested and works.
618+
*/
619+
620+
const promiseLike = {
621+
// oxlint-disable-next-line no-thenable promise-function-async require-await
622+
then<TResult1 = T, TResult2 = never>(
623+
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
624+
onrejected?:
625+
| ((reason: unknown) => TResult2 | PromiseLike<TResult2>)
626+
| null,
627+
): Promise<TResult1 | TResult2> {
628+
return currentPromise.then(onfulfilled, onrejected);
629+
},
630+
// oxlint-disable-next-line promise-function-async
631+
catch<TResult = never>(
632+
onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
633+
): Promise<T | TResult> {
634+
return currentPromise.catch(onrejected);
635+
},
636+
// oxlint-disable-next-line promise-function-async
637+
finally(onfinally?: (() => void) | null): Promise<T> {
638+
return currentPromise.finally(onfinally);
639+
},
640+
[Symbol.toStringTag]: "Promise" as const,
641+
};
642+
643+
const reset = (): void => {
644+
currentPromise = new Promise<T>((res, rej) => {
645+
innerResolve = res;
646+
innerReject = rej;
647+
});
648+
};
649+
650+
// Wrapper functions that always call the current resolver/rejecter
651+
const resolve = (value: T): void => {
652+
innerResolve(value);
653+
};
654+
655+
const reject = (reason?: unknown): void => {
656+
innerReject(reason);
657+
};
658+
659+
return {
660+
resolve,
661+
reject,
662+
promise: promiseLike as Promise<T>,
663+
reset,
664+
};
613665
}
614666

615667
/**

0 commit comments

Comments
 (0)