This commit is contained in:
Miodec 2025-12-20 21:20:40 +01:00
parent d11bdaa710
commit b32693926d
4 changed files with 297 additions and 14 deletions

View file

@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { getErrorMessage, isObject, escapeHTML } from "../../src/ts/utils/misc";
import { describe, it, expect, vi } from "vitest";
import {
getErrorMessage,
isObject,
escapeHTML,
promiseWithResolvers,
} from "../../src/ts/utils/misc";
import {
getLanguageDisplayString,
removeLanguageSize,
@ -218,4 +223,225 @@ describe("misc.ts", () => {
});
});
});
describe("promiseWithResolvers", () => {
it("should resolve the promise from outside", async () => {
//GIVEN
const { promise, resolve } = promiseWithResolvers<number>();
//WHEN
resolve(42);
//THEN
await expect(promise).resolves.toBe(42);
});
it("should resolve new promise after reset using same promise reference", async () => {
const { promise, resolve, reset } = promiseWithResolvers<number>();
const firstPromise = promise;
reset();
resolve(10);
await expect(firstPromise).resolves.toBe(10);
expect(promise).toBe(firstPromise);
});
it("should reject the promise from outside", async () => {
//GIVEN
const { promise, reject } = promiseWithResolvers<number>();
const error = new Error("test error");
//WHEN
reject(error);
//THEN
await expect(promise).rejects.toThrow("test error");
});
it("should work with void type", async () => {
//GIVEN
const { promise, resolve } = promiseWithResolvers();
//WHEN
resolve();
//THEN
await expect(promise).resolves.toBeUndefined();
});
it("should allow multiple resolves (only first takes effect)", async () => {
//GIVEN
const { promise, resolve } = promiseWithResolvers<number>();
//WHEN
resolve(42);
resolve(100); // This should have no effect
//THEN
await expect(promise).resolves.toBe(42);
});
it("should reset and create a new promise", async () => {
//GIVEN
const wrapper = promiseWithResolvers<number>();
wrapper.resolve(42);
await expect(wrapper.promise).resolves.toBe(42);
//WHEN
wrapper.reset();
wrapper.resolve(100);
//THEN
await expect(wrapper.promise).resolves.toBe(100);
});
it("should keep the same promise reference after reset", async () => {
//GIVEN
const wrapper = promiseWithResolvers<number>();
const firstPromise = wrapper.promise;
wrapper.resolve(42);
await expect(firstPromise).resolves.toBe(42);
//WHEN
wrapper.reset();
const secondPromise = wrapper.promise;
wrapper.resolve(100);
//THEN
expect(firstPromise).toBe(secondPromise); // Same reference
await expect(wrapper.promise).resolves.toBe(100);
});
it("should allow reject after reset", async () => {
//GIVEN
const wrapper = promiseWithResolvers<number>();
wrapper.resolve(42);
await wrapper.promise;
//WHEN
wrapper.reset();
const error = new Error("after reset");
wrapper.reject(error);
//THEN
await expect(wrapper.promise).rejects.toThrow("after reset");
});
it("should work with complex types", async () => {
//GIVEN
type ComplexType = { id: number; data: string[] };
const { promise, resolve } = promiseWithResolvers<ComplexType>();
const data: ComplexType = { id: 1, data: ["a", "b", "c"] };
//WHEN
resolve(data);
//THEN
await expect(promise).resolves.toEqual(data);
});
it("should handle rejection with non-Error values", async () => {
//GIVEN
const { promise, reject } = promiseWithResolvers<number>();
//WHEN
reject("string error");
//THEN
await expect(promise).rejects.toBe("string error");
});
it("should allow chaining with then/catch", async () => {
//GIVEN
const { promise, resolve } = promiseWithResolvers<number>();
const onFulfilled = vi.fn((value) => value * 2);
const chained = promise.then(onFulfilled);
//WHEN
resolve(21);
//THEN
await expect(chained).resolves.toBe(42);
expect(onFulfilled).toHaveBeenCalledWith(21);
});
it("should support async/await patterns", async () => {
//GIVEN
const { promise, resolve } = promiseWithResolvers<string>();
//WHEN
setTimeout(() => resolve("delayed"), 10);
//THEN
const result = await promise;
expect(result).toBe("delayed");
});
it("should maintain independent state for multiple instances", async () => {
//GIVEN
const first = promiseWithResolvers<number>();
const second = promiseWithResolvers<number>();
//WHEN
first.resolve(1);
second.resolve(2);
//THEN
await expect(first.promise).resolves.toBe(1);
await expect(second.promise).resolves.toBe(2);
});
it("should handle reset before resolve", async () => {
//GIVEN
const wrapper = promiseWithResolvers<number>();
const oldPromise = wrapper.promise;
//WHEN
wrapper.reset();
wrapper.resolve(42);
//THEN
await expect(wrapper.promise).resolves.toBe(42);
// Old promise should never resolve
const race = await Promise.race([
oldPromise.then(() => "old"),
Promise.resolve("timeout"),
]);
expect(race).toBe("timeout");
});
it("should work with Promise.all", async () => {
//GIVEN
const first = promiseWithResolvers<number>();
const second = promiseWithResolvers<number>();
const third = promiseWithResolvers<number>();
//WHEN
first.resolve(1);
second.resolve(2);
third.resolve(3);
//THEN
await expect(
Promise.all([first.promise, second.promise, third.promise]),
).resolves.toEqual([1, 2, 3]);
});
it("should work with Promise.race", async () => {
//GIVEN
const first = promiseWithResolvers<string>();
const second = promiseWithResolvers<string>();
//WHEN
first.resolve("first");
setTimeout(() => second.resolve("second"), 100);
//THEN
await expect(Promise.race([first.promise, second.promise])).resolves.toBe(
"first",
);
});
});
});

View file

@ -3,7 +3,11 @@ import AnimatedModal from "../utils/animated-modal";
import { promiseWithResolvers } from "../utils/misc";
import * as Notifications from "../elements/notifications";
let { promise, resolve } = promiseWithResolvers<string | undefined>();
const {
promise,
resolve,
reset: resetPromise,
} = promiseWithResolvers<string | undefined>();
export { promise };
@ -20,7 +24,7 @@ export async function show(): Promise<void> {
await modal.show({
mode: "dialog",
beforeAnimation: async (modal) => {
({ promise, resolve } = promiseWithResolvers<string | undefined>());
resetPromise();
CaptchaController.reset("register");
CaptchaController.render(

View file

@ -67,16 +67,18 @@ export function setIsDirectionReversed(val: boolean): void {
isDirectionReversed = val;
}
let { promise: testRestartingPromise, resolve: restartingResolve } =
promiseWithResolvers();
const {
promise: testRestartingPromise,
resolve: restartingResolve,
reset: resetTestRestarting,
} = promiseWithResolvers();
export { testRestartingPromise };
export function setTestRestarting(val: boolean): void {
testRestarting = val;
if (val) {
({ promise: testRestartingPromise, resolve: restartingResolve } =
promiseWithResolvers());
resetTestRestarting();
} else {
restartingResolve();
}

View file

@ -597,19 +597,70 @@ export function applyReducedMotion(animationTime: number): number {
/**
* Creates a promise with resolvers.
* This is useful for creating a promise that can be resolved or rejected from outside the promise itself.
* The returned promise reference stays constant even after reset() - it will always await the current internal promise.
*/
export function promiseWithResolvers<T = void>(): {
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
promise: Promise<T>;
reset: () => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
let innerResolve!: (value: T) => void;
let innerReject!: (reason?: unknown) => void;
let currentPromise = new Promise<T>((res, rej) => {
innerResolve = res;
innerReject = rej;
});
return { resolve, reject, promise };
/**
* This was fully AI generated to make the reset function work. Black magic, but its unit-tested and works.
*/
const promiseLike = {
// oxlint-disable-next-line no-thenable promise-function-async require-await
then<TResult1 = T, TResult2 = never>(
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?:
| ((reason: unknown) => TResult2 | PromiseLike<TResult2>)
| null,
): Promise<TResult1 | TResult2> {
return currentPromise.then(onfulfilled, onrejected);
},
// oxlint-disable-next-line promise-function-async
catch<TResult = never>(
onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
): Promise<T | TResult> {
return currentPromise.catch(onrejected);
},
// oxlint-disable-next-line promise-function-async
finally(onfinally?: (() => void) | null): Promise<T> {
return currentPromise.finally(onfinally);
},
[Symbol.toStringTag]: "Promise" as const,
};
const reset = (): void => {
currentPromise = new Promise<T>((res, rej) => {
innerResolve = res;
innerReject = rej;
});
};
// Wrapper functions that always call the current resolver/rejecter
const resolve = (value: T): void => {
innerResolve(value);
};
const reject = (reason?: unknown): void => {
innerReject(reason);
};
return {
resolve,
reject,
promise: promiseLike as Promise<T>,
reset,
};
}
/**