From 82cd37b61dc01799711ba9c51554e909ad47c468 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 27 Sep 2026 19:37:41 +0100 Subject: [PATCH] Improve typing, lint & format --- frontend/sync-client/src/utils/abortable.ts | 3 +- frontend/sync-client/src/utils/await-all.ts | 26 +++----- .../data-structures/event-listeners.test.ts | 34 ++++++++++ .../utils/data-structures/event-listeners.ts | 13 ++-- .../utils/data-structures/fix-sized-cache.ts | 11 +++- .../src/utils/data-structures/locks.test.ts | 15 +++-- frontend/sync-client/src/utils/decode-text.ts | 5 ++ .../sync-client/src/utils/get-random-color.ts | 1 + .../sync-client/src/utils/globs-to-regexes.ts | 1 + frontend/sync-client/src/utils/is-binary.ts | 10 +-- .../src/utils/is-file-type-mergeable.ts | 6 +- .../src/utils/line-and-column-to-position.ts | 9 +-- .../src/utils/position-to-line-and-column.ts | 2 +- frontend/sync-client/src/utils/rate-limit.ts | 66 +++++-------------- .../src/utils/remove-from-array.ts | 1 + 15 files changed, 109 insertions(+), 94 deletions(-) create mode 100644 frontend/sync-client/src/utils/decode-text.ts diff --git a/frontend/sync-client/src/utils/abortable.ts b/frontend/sync-client/src/utils/abortable.ts index 96044429..27110a04 100644 --- a/frontend/sync-client/src/utils/abortable.ts +++ b/frontend/sync-client/src/utils/abortable.ts @@ -13,6 +13,7 @@ export async function abortable( // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Only reset errors and timeout errors reach this helper. reject(signal.reason); }; + signal.addEventListener("abort", abort, { once: true }); }), operation() @@ -20,4 +21,4 @@ export async function abortable( } finally { signal.removeEventListener("abort", abort); } -} \ No newline at end of file +} diff --git a/frontend/sync-client/src/utils/await-all.ts b/frontend/sync-client/src/utils/await-all.ts index 9406a6b8..b69e6c0b 100644 --- a/frontend/sync-client/src/utils/await-all.ts +++ b/frontend/sync-client/src/utils/await-all.ts @@ -1,25 +1,19 @@ -type PromiseTuple = readonly [ - ...{ [K in keyof T]: Promise } -]; - type ResolvedTuple = { - [K in keyof T]: T[K]; + -readonly [K in keyof T]: Awaited; }; -export const awaitAll = async ( - promises: PromiseTuple -): Promise> => { +/** Await every operation before returning results or throwing the first error. */ +export async function awaitAll( + promises: T +): Promise> { // eslint-disable-next-line no-restricted-properties const result = await Promise.allSettled(promises); - for (const res of result) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Array.map preserves tuple order and length. + return result.map((res) => { if (res.status === "rejected") { throw res.reason; } - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return result.map( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (res) => (res as PromiseFulfilledResult).value - ) as ResolvedTuple; -}; + return res.value; + }) as ResolvedTuple; +} diff --git a/frontend/sync-client/src/utils/data-structures/event-listeners.test.ts b/frontend/sync-client/src/utils/data-structures/event-listeners.test.ts index 47c850f9..af74c76a 100644 --- a/frontend/sync-client/src/utils/data-structures/event-listeners.test.ts +++ b/frontend/sync-client/src/utils/data-structures/event-listeners.test.ts @@ -66,8 +66,10 @@ describe("EventListeners", () => { const listeners = new EventListeners<() => void>(); // eslint-disable-next-line @typescript-eslint/no-empty-function const listener1 = (): void => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function const listener2 = (): void => {}; + // eslint-disable-next-line @typescript-eslint/no-empty-function const listener3 = (): void => {}; @@ -114,6 +116,7 @@ describe("EventListeners", () => { const listener1 = (): void => { count1++; }; + const listener2 = (): void => { count2++; }; @@ -156,6 +159,37 @@ describe("EventListeners", () => { assert.strictEqual(results.length, 3); }); + it("awaits every async listener even when another listener throws synchronously", async () => { + const listeners = new EventListeners<() => unknown>(); + const release = Promise.withResolvers(); + const error = new Error("observer failed"); + let completed = false; + let laterListenerCalled = false; + listeners.add(async () => { + await release.promise; + completed = true; + }); + listeners.add(() => { + throw error; + }); + listeners.add(() => { + laterListenerCalled = true; + }); + + let settled = false; + const result = assert + .rejects(listeners.triggerAsync(), error) + .then(() => { + settled = true; + }); + await Promise.resolve(); + assert(laterListenerCalled); + assert(!settled); + release.resolve(undefined); + await result; + assert(completed); + }); + it("should not trigger cleared listeners", () => { const listeners = new EventListeners<() => void>(); let called = false; diff --git a/frontend/sync-client/src/utils/data-structures/event-listeners.ts b/frontend/sync-client/src/utils/data-structures/event-listeners.ts index 42a13eac..b1fd7219 100644 --- a/frontend/sync-client/src/utils/data-structures/event-listeners.ts +++ b/frontend/sync-client/src/utils/data-structures/event-listeners.ts @@ -12,7 +12,7 @@ export class EventListeners any> { private readonly onError: (error: unknown) => void = (error) => { console.error("Event observer failed", error); } - ) { } + ) {} public get count(): number { return this.listeners.length; @@ -69,14 +69,9 @@ export class EventListeners any> { */ public async triggerAsync(...args: Parameters): Promise { await awaitAll( - this.listeners - .map((listener) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return listener(...args); - }) - .filter((result): result is Promise => { - return result instanceof Promise; - }) + [...this.listeners].map(async (listener) => { + await listener(...args); + }) ); } diff --git a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts index 02954a83..14e9e3e0 100644 --- a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts +++ b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts @@ -5,7 +5,7 @@ export class FixedSizeDocumentCache { private currentSizeInBytes = 0; private readonly cache = new Map(); - public constructor(private maxSizeInBytes: number) { } + public constructor(private maxSizeInBytes: number) {} public get(updateId: VaultUpdateId): Uint8Array | undefined { const content = this.cache.get(updateId); @@ -19,7 +19,9 @@ export class FixedSizeDocumentCache { } public put(updateId: VaultUpdateId, content: Uint8Array): void { - if (content.byteLength > this.maxSizeInBytes) return; + if (content.byteLength > this.maxSizeInBytes) { + return; + } this.currentSizeInBytes -= this.cache.get(updateId)?.byteLength ?? 0; this.cache.delete(updateId); @@ -41,7 +43,10 @@ export class FixedSizeDocumentCache { private fitBelowMaxSize(): void { for (const [id, content] of this.cache) { - if (this.currentSizeInBytes <= this.maxSizeInBytes) break; + if (this.currentSizeInBytes <= this.maxSizeInBytes) { + break; + } + this.cache.delete(id); this.currentSizeInBytes -= content.byteLength; } diff --git a/frontend/sync-client/src/utils/data-structures/locks.test.ts b/frontend/sync-client/src/utils/data-structures/locks.test.ts index e325496b..9819a5e6 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.test.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.test.ts @@ -4,12 +4,12 @@ import { Lock } from "./locks"; test("queued operations run in order and retain their results", async () => { const lock = new Lock(); - const held = Promise.withResolvers(); - const started = Promise.withResolvers(); + const held = Promise.withResolvers(); + const started = Promise.withResolvers(); const calls: number[] = []; const first = lock.withLock(async () => { calls.push(1); - started.resolve(); + started.resolve(undefined); await held.promise; calls.push(2); return "first"; @@ -24,7 +24,7 @@ test("queued operations run in order and retain their results", async () => { }); await started.promise; assert.deepEqual(calls, [1]); - held.resolve(); + held.resolve(undefined); assert.equal(await first, "first"); assert.equal(await second, "second"); assert.equal(await third, "third"); @@ -35,8 +35,11 @@ for (const asynchronous of [false, true]) { test(`a ${asynchronous ? "rejected promise" : "thrown error"} releases the lock`, async () => { const lock = new Lock(); const error = new Error("failed operation"); - const failed = lock.withLock(() => { - if (asynchronous) return Promise.reject(error); + const failed = lock.withLock(async () => { + if (asynchronous) { + return Promise.reject(error); + } + throw error; }); const next = lock.withLock(() => "continued"); diff --git a/frontend/sync-client/src/utils/decode-text.ts b/frontend/sync-client/src/utils/decode-text.ts new file mode 100644 index 00000000..73f605fd --- /dev/null +++ b/frontend/sync-client/src/utils/decode-text.ts @@ -0,0 +1,5 @@ +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +export function decodeText(content: Uint8Array): string { + return decoder.decode(content); +} diff --git a/frontend/sync-client/src/utils/get-random-color.ts b/frontend/sync-client/src/utils/get-random-color.ts index 38015734..8e828ef1 100644 --- a/frontend/sync-client/src/utils/get-random-color.ts +++ b/frontend/sync-client/src/utils/get-random-color.ts @@ -4,6 +4,7 @@ export function getRandomColor(name: string): string { hash = (hash << 5) - hash + name.charCodeAt(i); hash |= 0; // Convert to 32bit integer } + const normalised = hash / 0x7fffffff; return `oklch(0.58 0.15 ${Math.round(Math.abs(normalised * 360))})`; } diff --git a/frontend/sync-client/src/utils/globs-to-regexes.ts b/frontend/sync-client/src/utils/globs-to-regexes.ts index 1cd048d3..291b4e9e 100644 --- a/frontend/sync-client/src/utils/globs-to-regexes.ts +++ b/frontend/sync-client/src/utils/globs-to-regexes.ts @@ -13,6 +13,7 @@ export function globsToRegexes(globs: string[], logger: Logger): RegExp[] { `Failed to parse ${pattern}' as a glob pattern, skipping it` ); } + return result; }) // eslint-disable-next-line no-restricted-syntax -- Filtering out false values, not removing a specific item diff --git a/frontend/sync-client/src/utils/is-binary.ts b/frontend/sync-client/src/utils/is-binary.ts index aac92711..494c71ab 100644 --- a/frontend/sync-client/src/utils/is-binary.ts +++ b/frontend/sync-client/src/utils/is-binary.ts @@ -1,13 +1,13 @@ +import { decodeText } from "./decode-text"; + // Text is unlikely to contain null bytes, so we can use that to distinguish binary files. export function isBinary(content: Uint8Array): boolean { - for (const byte of content) { - if (byte === 0) { - return true; - } + if (content.includes(0)) { + return true; } try { - new TextDecoder("utf-8", { fatal: true }).decode(content); + decodeText(content); } catch { return true; } diff --git a/frontend/sync-client/src/utils/is-file-type-mergeable.ts b/frontend/sync-client/src/utils/is-file-type-mergeable.ts index d62dc270..1ece789b 100644 --- a/frontend/sync-client/src/utils/is-file-type-mergeable.ts +++ b/frontend/sync-client/src/utils/is-file-type-mergeable.ts @@ -4,7 +4,11 @@ export function isFileTypeMergeable( ): boolean { const name = pathOrFileName.split("/").at(-1) ?? ""; const separator = name.lastIndexOf("."); - if (separator < 0) return false; + + if (separator < 0) { + return false; + } + const fileExtension = name.slice(separator + 1); return mergeableExtensions.includes(fileExtension.toLowerCase()); diff --git a/frontend/sync-client/src/utils/line-and-column-to-position.ts b/frontend/sync-client/src/utils/line-and-column-to-position.ts index 05ac7be8..f1fd1344 100644 --- a/frontend/sync-client/src/utils/line-and-column-to-position.ts +++ b/frontend/sync-client/src/utils/line-and-column-to-position.ts @@ -15,17 +15,18 @@ export function lineAndColumnToPosition( ): number { const lines = text.replaceAll("\r", "").split("\n"); - if (line >= lines.length) { + const selectedLine = lines[line]; + if (selectedLine === undefined) { throw new Error(`Line number ${line} is out of range.`); } - if (column > lines[line].length) { + if (column > selectedLine.length) { throw new Error(`Column number ${column} is out of range.`); } let position = 0; - for (let i = 0; i < line; i++) { - position += lines[i].length + 1; + for (const precedingLine of lines.slice(0, line)) { + position += precedingLine.length + 1; } position += column; diff --git a/frontend/sync-client/src/utils/position-to-line-and-column.ts b/frontend/sync-client/src/utils/position-to-line-and-column.ts index 969171d8..be0828fb 100644 --- a/frontend/sync-client/src/utils/position-to-line-and-column.ts +++ b/frontend/sync-client/src/utils/position-to-line-and-column.ts @@ -27,7 +27,7 @@ export function positionToLineAndColumn( const lines = textUpToPosition.split("\n"); const line = lines.length - 1; - const column = lines[lines.length - 1].length; + const column = (lines.at(-1) ?? "").length; return { line, column }; } diff --git a/frontend/sync-client/src/utils/rate-limit.ts b/frontend/sync-client/src/utils/rate-limit.ts index 52cbbce7..04f66b62 100644 --- a/frontend/sync-client/src/utils/rate-limit.ts +++ b/frontend/sync-client/src/utils/rate-limit.ts @@ -1,63 +1,33 @@ -import { createPromise } from "./create-promise"; import { sleep } from "./sleep"; -/** - * Creates a rate-limited version of a given asynchronous function. - * Ensures that the function is not called more frequently than specified by `minIntervalMs`. - * If the function is called while a previous call is still within the rate limit window, - * it will queue up the most recent arguments and execute them after the rate limit expires. - * Only the most recent call is preserved in the queue. - * - * @template T - Type of the function to be rate limited - * @param {T} fn - The asynchronous function to rate limit - * @param {number | (() => number)} minIntervalMs - Minimum interval in milliseconds between calls, - * or a function that returns the minimum interval - * @returns {(...args: Parameters) => ReturnType | Promise} A decorated function that respects the rate limit. - * Returns the original function's return type when executed, or undefined if the call was superseded by a newer one. - */ -export function rateLimit< - R, - T extends ( - ...args: any // eslint-disable-line @typescript-eslint/no-explicit-any - ) => Promise ->( - fn: T, +/** Coalesce calls during each interval; the first waiter runs the latest arguments. */ +export function rateLimit( + fn: (...args: Args) => Promise, minIntervalMs: number | (() => number) -): (...args: Parameters) => Promise { - let newArgs: Parameters | undefined = undefined; - let running: Promise | undefined = undefined; +): (...args: Args) => Promise { + let pending: Args | undefined = undefined; + let cooldown: Promise | undefined = undefined; - const decoratedFn = async ( - ...args: Parameters - ): Promise => { - if (running !== undefined) { - newArgs = args; - await running; + return async (...args: Args): Promise => { + if (cooldown) { + pending = args; + await cooldown; - // args might have changed while we were waiting - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (newArgs === undefined) { - // we weren't the first one to wake up, that means a newer - // invocation is running now, we can just bail + // Another waiter may already have consumed the latest arguments. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Other calls mutate pending while awaiting the cooldown. + if (pending === undefined) { return; } - args = newArgs; - newArgs = undefined; + + args = pending; + pending = undefined; } - const [promise, resolve] = createPromise(); - running = promise; - sleep( + cooldown = sleep( typeof minIntervalMs === "function" ? minIntervalMs() : minIntervalMs - ) - .then(resolve) - .catch(() => { - // sleep cannot fail - }); + ); return fn(...args); }; - - return decoratedFn; } diff --git a/frontend/sync-client/src/utils/remove-from-array.ts b/frontend/sync-client/src/utils/remove-from-array.ts index 393b062f..1c97cd7a 100644 --- a/frontend/sync-client/src/utils/remove-from-array.ts +++ b/frontend/sync-client/src/utils/remove-from-array.ts @@ -13,5 +13,6 @@ export function removeFromArray(array: T[], item: T): boolean { array.splice(index, 1); return true; } + return false; }