diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index b2ed7a35..d88a042f 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -55,6 +55,25 @@ export default [ message: "Use replaceAll instead of replace to replace all occurrences of a substring." } ], + "no-restricted-syntax": [ + "error", + { + selector: "CallExpression[callee.property.name='splice'][arguments.length=2][arguments.1.type='Literal'][arguments.1.value=1]", + message: "Use `removeFromArray(array, item)` instead of manually using indexOf + splice(index, 1). Import from 'sync-client/src/utils/remove-from-array'." + }, + { + selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression[body.type='BinaryExpression'][body.operator='!==']", + message: "Use `removeFromArray(array, item)` instead of filter(x => x !== item) for better performance. Import from 'sync-client/src/utils/remove-from-array'." + }, + { + selector: "CallExpression[callee.property.name='filter'] > ArrowFunctionExpression > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']", + message: "Use `removeFromArray(array, item)` instead of filter(x => { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'." + }, + { + selector: "CallExpression[callee.property.name='filter'] > FunctionExpression[body.type='BlockStatement'] > BlockStatement > ReturnStatement > BinaryExpression[operator='!==']", + message: "Use `removeFromArray(array, item)` instead of filter(function(x) { return x !== item }) for better performance. Import from 'sync-client/src/utils/remove-from-array'." + } + ], "unused-imports/no-unused-vars": [ "warn", { diff --git a/frontend/obsidian-plugin/src/views/status-description/status-description.ts b/frontend/obsidian-plugin/src/views/status-description/status-description.ts index 666c107b..fe4f17dc 100644 --- a/frontend/obsidian-plugin/src/views/status-description/status-description.ts +++ b/frontend/obsidian-plugin/src/views/status-description/status-description.ts @@ -5,13 +5,14 @@ import type { NetworkConnectionStatus, SyncClient } from "sync-client"; +import { utils } from "sync-client"; export class StatusDescription { private lastHistoryStats: HistoryStats | undefined; private lastRemaining: number | undefined; private lastConnectionState: NetworkConnectionStatus | undefined; - private statusChangeListeners: (() => unknown)[] = []; + private readonly statusChangeListeners: (() => unknown)[] = []; public constructor(private readonly syncClient: SyncClient) { void this.updateConnectionState(); @@ -46,9 +47,7 @@ export class StatusDescription { this.statusChangeListeners.push(listener); } public removeStatusChangeListener(listener: () => unknown): void { - this.statusChangeListeners = this.statusChangeListeners.filter( - (l) => l !== listener - ); + utils.removeFromArray(this.statusChangeListeners, listener); } public renderStatusDescription(container: HTMLElement): void { diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index a7292ec2..405acb10 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -5,6 +5,7 @@ import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory" import { getRandomColor } from "./utils/get-random-color"; import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; import { positionToLineAndColumn } from "./utils/position-to-line-and-column"; +import { removeFromArray } from "./utils/remove-from-array"; export { SyncType, @@ -43,5 +44,6 @@ export const utils = { getRandomColor, positionToLineAndColumn, lineAndColumnToPosition, - awaitAll + awaitAll, + removeFromArray }; diff --git a/frontend/sync-client/src/persistence/database.ts b/frontend/sync-client/src/persistence/database.ts index d42651ae..5568169b 100644 --- a/frontend/sync-client/src/persistence/database.ts +++ b/frontend/sync-client/src/persistence/database.ts @@ -2,6 +2,7 @@ import type { Logger } from "../tracing/logger"; import { EMPTY_HASH } from "../utils/hash"; import { CoveredValues } from "../utils/data-structures/min-covered"; import { awaitAll } from "../utils/await-all"; +import { removeFromArray } from "../utils/remove-from-array"; export type VaultUpdateId = number; export type DocumentId = string; @@ -93,6 +94,7 @@ export class Database { public get resolvedDocuments(): DocumentRecord[] { const paths = new Map(); this.documents + // eslint-disable-next-line no-restricted-syntax -- Type narrowing, not removing a specific item .filter(({ metadata }) => metadata !== undefined) .forEach((record) => paths.set(record.relativePath, [ @@ -151,12 +153,12 @@ export class Database { return; } - entry.updates = entry.updates.filter((update) => update !== promise); + removeFromArray(entry.updates, promise); // No need to save as Promises don't get serialized } public removeDocument(find: DocumentRecord): void { - this.documents = this.documents.filter((document) => document !== find); + removeFromArray(this.documents, find); this.saveInTheBackground(); } diff --git a/frontend/sync-client/src/persistence/settings.ts b/frontend/sync-client/src/persistence/settings.ts index 81044a38..8472155a 100644 --- a/frontend/sync-client/src/persistence/settings.ts +++ b/frontend/sync-client/src/persistence/settings.ts @@ -1,6 +1,7 @@ import type { Logger } from "../tracing/logger"; import { awaitAll } from "../utils/await-all"; import { Lock } from "../utils/data-structures/locks"; +import { removeFromArray } from "../utils/remove-from-array"; export interface SyncSettings { remoteUri: string; @@ -69,10 +70,7 @@ export class Settings { public removeOnSettingsChangeListener( listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown ): void { - const index = this.onSettingsChangeHandlers.indexOf(listener); - if (index !== -1) { - this.onSettingsChangeHandlers.splice(index, 1); - } + removeFromArray(this.onSettingsChangeHandlers, listener); } public async setSetting( diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 015a778e..0dc19d60 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -8,6 +8,7 @@ import { createPromise } from "../utils/create-promise"; import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate"; import { awaitAll } from "../utils/await-all"; import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts"; +import { removeFromArray } from "../utils/remove-from-array"; export class WebSocketManager { private readonly webSocketStatusChangeListeners: (( @@ -227,12 +228,10 @@ export class WebSocketManager { ); }) .finally(() => { - const index = this.outstandingPromises.indexOf( + removeFromArray( + this.outstandingPromises, messageHandlingPromise ); - if (index !== -1) { - void this.outstandingPromises.splice(index, 1); // ignore the returned promise - } }); void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise diff --git a/frontend/sync-client/src/sync-operations/file-change-notifier.ts b/frontend/sync-client/src/sync-operations/file-change-notifier.ts index 2c099b6f..d2b40c1f 100644 --- a/frontend/sync-client/src/sync-operations/file-change-notifier.ts +++ b/frontend/sync-client/src/sync-operations/file-change-notifier.ts @@ -1,4 +1,5 @@ import type { RelativePath } from "../persistence/database"; +import { removeFromArray } from "../utils/remove-from-array"; export class FileChangeNotifier { private readonly listeners: ((filePath: RelativePath) => unknown)[] = []; @@ -12,10 +13,7 @@ export class FileChangeNotifier { public removeFileChangeListener( listener: (filePath: RelativePath) => unknown ): void { - const index = this.listeners.indexOf(listener); - if (index !== -1) { - this.listeners.splice(index, 1); - } + removeFromArray(this.listeners, listener); } public notifyOfFileChange(filePath: RelativePath): void { diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 12008b59..65cd020c 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -444,11 +444,13 @@ export class Syncer { ); if (originalFile !== undefined) { // `originalFile` hasn't been deleted but it got moved instead + /* eslint-disable no-restricted-syntax -- Comparing by property, not direct equality */ locallyPossiblyDeletedFiles = locallyPossiblyDeletedFiles.filter( (item) => item.relativePath !== originalFile.relativePath ); + /* eslint-enable no-restricted-syntax */ this.logger.debug( `Document '${originalFile.relativePath}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it` diff --git a/frontend/sync-client/src/tracing/logger.ts b/frontend/sync-client/src/tracing/logger.ts index 96b93b0d..41e25257 100644 --- a/frontend/sync-client/src/tracing/logger.ts +++ b/frontend/sync-client/src/tracing/logger.ts @@ -1,4 +1,5 @@ import { MAX_LOG_MESSAGE_COUNT } from "../consts"; +import { removeFromArray } from "../utils/remove-from-array"; export enum LogLevel { DEBUG = "DEBUG", @@ -63,10 +64,7 @@ export class Logger { public removeOnMessageListener( listener: (message: LogLine) => unknown ): void { - const index = this.onMessageListeners.indexOf(listener); - if (index !== -1) { - this.onMessageListeners.splice(index, 1); - } + removeFromArray(this.onMessageListeners, listener); } public reset(): void { diff --git a/frontend/sync-client/src/tracing/sync-history.ts b/frontend/sync-client/src/tracing/sync-history.ts index 0fb1a754..d60a57d1 100644 --- a/frontend/sync-client/src/tracing/sync-history.ts +++ b/frontend/sync-client/src/tracing/sync-history.ts @@ -4,6 +4,7 @@ import { } from "../consts"; import type { RelativePath } from "../persistence/database"; import type { Logger } from "./logger"; +import { removeFromArray } from "../utils/remove-from-array"; export interface SyncCreateDetails { type: SyncType.CREATE; @@ -68,7 +69,7 @@ export interface HistoryStats { } export class SyncHistory { - private _entries: HistoryEntry[] = []; + private readonly _entries: HistoryEntry[] = []; private readonly syncHistoryUpdateListeners: (( status: HistoryStats @@ -99,7 +100,7 @@ export class SyncHistory { const candidate = this.findSimilarRecentUpdateEntry(historyEntry); if (candidate !== undefined) { - this._entries = this._entries.filter((e) => e !== candidate); + removeFromArray(this._entries, candidate); } // Insert the entry at the beginning @@ -122,10 +123,7 @@ export class SyncHistory { public removeSyncHistoryUpdateListener( listener: (stats: HistoryStats) => unknown ): void { - const index = this.syncHistoryUpdateListeners.indexOf(listener); - if (index !== -1) { - this.syncHistoryUpdateListeners.splice(index, 1); - } + removeFromArray(this.syncHistoryUpdateListeners, listener); } public reset(): void { diff --git a/frontend/sync-client/src/utils/globs-to-regexes.ts b/frontend/sync-client/src/utils/globs-to-regexes.ts index 1e8ad775..5b8bf062 100644 --- a/frontend/sync-client/src/utils/globs-to-regexes.ts +++ b/frontend/sync-client/src/utils/globs-to-regexes.ts @@ -2,17 +2,20 @@ import { makeRe } from "minimatch"; import type { Logger } from "../tracing/logger"; export function globsToRegexes(globs: string[], logger: Logger): RegExp[] { - return globs - .map((pattern) => { - const result = makeRe(pattern, { - dot: true - }); - if (result === false) { - logger.warn( - `Failed to parse ${pattern}' as a glob pattern, skipping it` - ); - } - return result; - }) - .filter((pattern) => pattern !== false); + return ( + globs + .map((pattern) => { + const result = makeRe(pattern, { + dot: true + }); + if (result === false) { + logger.warn( + `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 + .filter((pattern) => pattern !== false) + ); } diff --git a/frontend/sync-client/src/utils/remove-from-array.ts b/frontend/sync-client/src/utils/remove-from-array.ts new file mode 100644 index 00000000..393b062f --- /dev/null +++ b/frontend/sync-client/src/utils/remove-from-array.ts @@ -0,0 +1,17 @@ +/** + * Efficiently removes a specific item from an array by modifying it in place. + * This is more efficient than using `.filter(item => item !== toRemove)` as it avoids creating a new array + * + * @param array The array to modify + * @param item The item to remove + * @returns true if the item was found and removed, false otherwise + */ +export function removeFromArray(array: T[], item: T): boolean { + const index = array.indexOf(item); + if (index !== -1) { + // eslint-disable-next-line no-restricted-syntax -- This is the implementation of the helper itself + array.splice(index, 1); + return true; + } + return false; +} diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index ac525685..824f5eee 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -15,7 +15,7 @@ export class MockAgent extends MockClient { private readonly pendingActions: Promise[] = []; // The renamed file finding algorithm isn't too smart so we can't both update and rename the same file - private doNotTouchWhileOffline: string[] = []; + private readonly doNotTouchWhileOffline: string[] = []; public constructor( initialSettings: Partial, @@ -54,10 +54,10 @@ export class MockAgent extends MockClient { ); if (historyEntry) { - this.doNotTouchWhileOffline = - this.doNotTouchWhileOffline.filter( - (file) => file !== historyEntry[1] - ); + utils.removeFromArray( + this.doNotTouchWhileOffline, + historyEntry[1] + ); } switch (logLine.level) { case LogLevel.ERROR: