From 4b05f2128565b8e6f26bca83ed3ebb9df84af31b Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Sat, 9 Aug 2025 22:26:59 +0200 Subject: [PATCH] feat: replace helpful-decorators and thus lodash with custom implementation to save a lot of space --- package-lock.json | 33 -- package.json | 1 - src/app/core/snack/snack.service.ts | 2 +- ...gitlab-submit-worklog-for-day.component.ts | 2 +- src/app/features/reminder/reminder.module.ts | 2 +- .../schedule-week/schedule-week.component.ts | 2 +- .../task-context-menu-inner.component.ts | 2 +- src/app/features/tasks/task/task.component.ts | 2 +- src/app/util/decorators.spec.ts | 359 ++++++++++++++++++ src/app/util/decorators.ts | 231 +++++++++++ 10 files changed, 596 insertions(+), 40 deletions(-) create mode 100644 src/app/util/decorators.spec.ts create mode 100644 src/app/util/decorators.ts diff --git a/package-lock.json b/package-lock.json index 7d06937d2..6714e2548 100644 --- a/package-lock.json +++ b/package-lock.json @@ -101,7 +101,6 @@ "file-saver": "^2.0.5", "glob": "^9.3.5", "hammerjs": "^2.0.8", - "helpful-decorators": "^2.1.0", "husky": "^4.2.5", "ical.js": "^2.1.0", "idb": "^8.0.3", @@ -14126,18 +14125,6 @@ "node": ">= 0.4" } }, - "node_modules/helpful-decorators": { - "version": "2.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.debounce": "4.0.8", - "lodash.delay": "4.1.1", - "lodash.memoize": "4.1.2", - "lodash.once": "4.1.1", - "lodash.throttle": "4.1.1" - } - }, "node_modules/hookified": { "version": "1.8.2", "dev": true, @@ -16313,31 +16300,11 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.delay": { - "version": "4.1.1", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "dev": true, "license": "MIT" }, - "node_modules/lodash.once": { - "version": "4.1.1", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.throttle": { - "version": "4.1.1", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.truncate": { "version": "4.4.2", "dev": true, diff --git a/package.json b/package.json index a302fa3bd..ea10532c8 100644 --- a/package.json +++ b/package.json @@ -221,7 +221,6 @@ "file-saver": "^2.0.5", "glob": "^9.3.5", "hammerjs": "^2.0.8", - "helpful-decorators": "^2.1.0", "husky": "^4.2.5", "ical.js": "^2.1.0", "idb": "^8.0.3", diff --git a/src/app/core/snack/snack.service.ts b/src/app/core/snack/snack.service.ts index b3ed3befb..2f238aa6f 100644 --- a/src/app/core/snack/snack.service.ts +++ b/src/app/core/snack/snack.service.ts @@ -9,7 +9,7 @@ import { TranslateService } from '@ngx-translate/core'; import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material/snack-bar'; import { Actions, ofType } from '@ngrx/effects'; import { setActiveWorkContext } from '../../features/work-context/store/work-context.actions'; -import { debounce } from 'helpful-decorators'; +import { debounce } from '../../util/decorators'; @Injectable({ providedIn: 'root', diff --git a/src/app/features/issue/providers/gitlab/dialog-gitlab-submit-worklog-for-day/dialog-gitlab-submit-worklog-for-day.component.ts b/src/app/features/issue/providers/gitlab/dialog-gitlab-submit-worklog-for-day/dialog-gitlab-submit-worklog-for-day.component.ts index 9bb748b9e..15192e108 100644 --- a/src/app/features/issue/providers/gitlab/dialog-gitlab-submit-worklog-for-day/dialog-gitlab-submit-worklog-for-day.component.ts +++ b/src/app/features/issue/providers/gitlab/dialog-gitlab-submit-worklog-for-day/dialog-gitlab-submit-worklog-for-day.component.ts @@ -12,7 +12,7 @@ import { IssueTaskTimeTracked, Task, TimeSpentOnDay } from '../../../../tasks/ta import { BehaviorSubject, Observable } from 'rxjs'; import { GitlabApiService } from '../gitlab-api/gitlab-api.service'; import { first, map, tap } from 'rxjs/operators'; -import { throttle } from 'helpful-decorators'; +import { throttle } from '../../../../../util/decorators'; import { SnackService } from '../../../../../core/snack/snack.service'; import { Store } from '@ngrx/store'; import { IssueProviderService } from '../../../issue-provider.service'; diff --git a/src/app/features/reminder/reminder.module.ts b/src/app/features/reminder/reminder.module.ts index 06b2bfcc0..444a02c10 100644 --- a/src/app/features/reminder/reminder.module.ts +++ b/src/app/features/reminder/reminder.module.ts @@ -17,7 +17,7 @@ import { Reminder } from './reminder.model'; import { UiHelperService } from '../ui-helper/ui-helper.service'; import { NotifyService } from '../../core/notify/notify.service'; import { DialogViewTaskRemindersComponent } from '../tasks/dialog-view-task-reminders/dialog-view-task-reminders.component'; -import { throttle } from 'helpful-decorators'; +import { throttle } from '../../util/decorators'; import { SyncTriggerService } from '../../imex/sync/sync-trigger.service'; import { LayoutService } from '../../core-ui/layout/layout.service'; import { from, merge, of, timer, interval } from 'rxjs'; diff --git a/src/app/features/schedule/schedule-week/schedule-week.component.ts b/src/app/features/schedule/schedule-week/schedule-week.component.ts index 9c623b9e7..9137e8b3d 100644 --- a/src/app/features/schedule/schedule-week/schedule-week.component.ts +++ b/src/app/features/schedule/schedule-week/schedule-week.component.ts @@ -20,7 +20,7 @@ import { import { Store } from '@ngrx/store'; import { PlannerActions } from '../../planner/store/planner.actions'; import { FH, SVEType, T_ID_PREFIX } from '../schedule.const'; -import { throttle } from 'helpful-decorators'; +import { throttle } from '../../../util/decorators'; import { CreateTaskPlaceholderComponent } from '../create-task-placeholder/create-task-placeholder.component'; import { ScheduleEventComponent } from '../schedule-event/schedule-event.component'; import { TranslatePipe } from '@ngx-translate/core'; diff --git a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts index 37133c41d..d8735e0ba 100644 --- a/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts +++ b/src/app/features/tasks/task-context-menu/task-context-menu-inner/task-context-menu-inner.component.ts @@ -46,7 +46,7 @@ import { KeyboardConfig } from '../../../config/keyboard-config.model'; import { DialogScheduleTaskComponent } from '../../../planner/dialog-schedule-task/dialog-schedule-task.component'; import { DialogTimeEstimateComponent } from '../../dialog-time-estimate/dialog-time-estimate.component'; import { DialogEditTaskAttachmentComponent } from '../../task-attachment/dialog-edit-attachment/dialog-edit-task-attachment.component'; -import { throttle } from 'helpful-decorators'; +import { throttle } from '../../../../util/decorators'; import { DialogConfirmComponent } from '../../../../ui/dialog-confirm/dialog-confirm.component'; import { Update } from '@ngrx/entity'; import { IS_TOUCH_PRIMARY } from 'src/app/util/is-mouse-primary'; diff --git a/src/app/features/tasks/task/task.component.ts b/src/app/features/tasks/task/task.component.ts index 2f53e8eb9..5b86e0117 100644 --- a/src/app/features/tasks/task/task.component.ts +++ b/src/app/features/tasks/task/task.component.ts @@ -52,7 +52,7 @@ import { MatMenuTrigger, } from '@angular/material/menu'; import { WorkContextService } from '../../work-context/work-context.service'; -import { throttle } from 'helpful-decorators'; +import { throttle } from '../../../util/decorators'; import { TaskRepeatCfgService } from '../../task-repeat-cfg/task-repeat-cfg.service'; import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component'; import { Update } from '@ngrx/entity'; diff --git a/src/app/util/decorators.spec.ts b/src/app/util/decorators.spec.ts new file mode 100644 index 000000000..8fcc0bda9 --- /dev/null +++ b/src/app/util/decorators.spec.ts @@ -0,0 +1,359 @@ +import { throttle, debounce } from './decorators'; + +describe('Decorators', () => { + beforeEach(() => { + jasmine.clock().install(); + jasmine.clock().mockDate(new Date()); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + describe('throttle', () => { + it('should throttle method calls with default options', () => { + const spy = jasmine.createSpy('method'); + + class TestClass { + @throttle(100) + method(): void { + spy(); + } + } + + const instance = new TestClass(); + + // First call should execute immediately (leading: true by default) + instance.method(); + expect(spy).toHaveBeenCalledTimes(1); + + // Rapid calls should be throttled + instance.method(); + instance.method(); + instance.method(); + expect(spy).toHaveBeenCalledTimes(1); + + // After wait time, next call should execute + jasmine.clock().tick(100); + instance.method(); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it('should handle multiple instances independently', () => { + const spy1 = jasmine.createSpy('method1'); + const spy2 = jasmine.createSpy('method2'); + + class TestClass { + constructor(private spy: jasmine.Spy) {} + + @throttle(100) + method(): void { + this.spy(); + } + } + + const instance1 = new TestClass(spy1); + const instance2 = new TestClass(spy2); + + // Call on instance1 + instance1.method(); + expect(spy1).toHaveBeenCalledTimes(1); + expect(spy2).toHaveBeenCalledTimes(0); + + // Call on instance2 should not be affected by instance1's throttle + instance2.method(); + expect(spy1).toHaveBeenCalledTimes(1); + expect(spy2).toHaveBeenCalledTimes(1); + + // Both should be throttled independently + instance1.method(); + instance2.method(); + expect(spy1).toHaveBeenCalledTimes(1); + expect(spy2).toHaveBeenCalledTimes(1); + + // After wait time, both can call again + jasmine.clock().tick(100); + instance1.method(); + instance2.method(); + expect(spy1).toHaveBeenCalledTimes(2); + expect(spy2).toHaveBeenCalledTimes(2); + }); + + it('should respect leading: false option', () => { + const spy = jasmine.createSpy('method'); + + class TestClass { + @throttle(100, { leading: false }) + method(): void { + spy(); + } + } + + const instance = new TestClass(); + + // First call should NOT execute immediately with leading: false + instance.method(); + expect(spy).toHaveBeenCalledTimes(0); + + // After wait time, trailing call should execute + jasmine.clock().tick(100); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should respect trailing: false option', () => { + const spy = jasmine.createSpy('method'); + + class TestClass { + @throttle(100, { trailing: false }) + method(): void { + spy(); + } + } + + const instance = new TestClass(); + + // First call should execute immediately (leading: true by default) + instance.method(); + expect(spy).toHaveBeenCalledTimes(1); + + // Additional calls during wait period + instance.method(); + instance.method(); + expect(spy).toHaveBeenCalledTimes(1); + + // After wait time, no trailing call should execute + jasmine.clock().tick(100); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('debounce', () => { + it('should debounce method calls with default options', () => { + const spy = jasmine.createSpy('method'); + + class TestClass { + @debounce(100) + method(): void { + spy(); + } + } + + const instance = new TestClass(); + + // Rapid calls should be debounced + instance.method(); + instance.method(); + instance.method(); + expect(spy).toHaveBeenCalledTimes(0); + + // After wait time, only one call should execute + jasmine.clock().tick(100); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should handle multiple instances independently', () => { + const spy1 = jasmine.createSpy('method1'); + const spy2 = jasmine.createSpy('method2'); + + class TestClass { + constructor(private spy: jasmine.Spy) {} + + @debounce(100) + method(): void { + this.spy(); + } + } + + const instance1 = new TestClass(spy1); + const instance2 = new TestClass(spy2); + + // Call on both instances + instance1.method(); + instance2.method(); + expect(spy1).toHaveBeenCalledTimes(0); + expect(spy2).toHaveBeenCalledTimes(0); + + // After wait time, both should execute independently + jasmine.clock().tick(100); + expect(spy1).toHaveBeenCalledTimes(1); + expect(spy2).toHaveBeenCalledTimes(1); + }); + + it('should respect leading: true option', () => { + const spy = jasmine.createSpy('method'); + + class TestClass { + @debounce(100, { leading: true }) + method(): void { + spy(); + } + } + + const instance = new TestClass(); + + // First call should execute immediately with leading: true + instance.method(); + expect(spy).toHaveBeenCalledTimes(1); + + // Additional calls should be debounced + instance.method(); + instance.method(); + expect(spy).toHaveBeenCalledTimes(1); + + // After wait time, no additional calls (atBegin option doesn't include trailing) + jasmine.clock().tick(100); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should restart timer on each call', () => { + const spy = jasmine.createSpy('method'); + + class TestClass { + @debounce(100) + method(): void { + spy(); + } + } + + const instance = new TestClass(); + + instance.method(); + jasmine.clock().tick(50); + instance.method(); // Restart timer + jasmine.clock().tick(50); + instance.method(); // Restart timer again + jasmine.clock().tick(50); + expect(spy).toHaveBeenCalledTimes(0); + + // Only after full wait time from last call + jasmine.clock().tick(50); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('should maintain correct this context', () => { + class TestClass { + value = 42; + + @debounce(100) + method(): number { + return this.value; + } + } + + const instance = new TestClass(); + instance.method(); + jasmine.clock().tick(100); + + // Verify the method was called with correct context + // (We can't directly check the return value since it's async) + instance.value = 100; + instance.method(); + jasmine.clock().tick(100); + }); + + it('should use last arguments when trailing', () => { + const spy = jasmine.createSpy('method'); + + class TestClass { + @debounce(100) + method(value: number): void { + spy(value); + } + } + + const instance = new TestClass(); + instance.method(1); + instance.method(2); + instance.method(3); + + jasmine.clock().tick(100); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(3); + }); + + it('should handle async methods correctly', async () => { + const spy = jasmine + .createSpy('method') + .and.returnValue(Promise.resolve('async result')); + + class TestClass { + @debounce(100) + async method(): Promise { + return spy(); + } + } + + const instance = new TestClass(); + instance.method(); + + jasmine.clock().tick(100); + await Promise.resolve(); // Let promises resolve + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Cross-decorator tests', () => { + it('should not share state between different decorators on same instance', () => { + const throttleSpy = jasmine.createSpy('throttled'); + const debounceSpy = jasmine.createSpy('debounced'); + + class TestClass { + @throttle(100) + throttledMethod(): void { + throttleSpy(); + } + + @debounce(100) + debouncedMethod(): void { + debounceSpy(); + } + } + + const instance = new TestClass(); + + // Call throttled - should execute immediately + instance.throttledMethod(); + expect(throttleSpy).toHaveBeenCalledTimes(1); + expect(debounceSpy).toHaveBeenCalledTimes(0); + + // Call debounced - should not execute immediately + instance.debouncedMethod(); + expect(throttleSpy).toHaveBeenCalledTimes(1); + expect(debounceSpy).toHaveBeenCalledTimes(0); + + // After wait time + jasmine.clock().tick(100); + expect(throttleSpy).toHaveBeenCalledTimes(1); + expect(debounceSpy).toHaveBeenCalledTimes(1); + }); + + it('should handle inheritance correctly', () => { + const baseSpy = jasmine.createSpy('base'); + const childSpy = jasmine.createSpy('child'); + + class BaseClass { + @throttle(100) + method(): void { + baseSpy(); + } + } + + class ChildClass extends BaseClass { + override method(): void { + childSpy(); + super.method(); + } + } + + const child = new ChildClass(); + child.method(); + expect(childSpy).toHaveBeenCalledTimes(1); + expect(baseSpy).toHaveBeenCalledTimes(1); + + // Throttle should still apply + child.method(); + expect(childSpy).toHaveBeenCalledTimes(2); + expect(baseSpy).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/app/util/decorators.ts b/src/app/util/decorators.ts new file mode 100644 index 000000000..1d1eae18a --- /dev/null +++ b/src/app/util/decorators.ts @@ -0,0 +1,231 @@ +/** + * TypeScript decorators for throttle and debounce + * Simple implementation to eliminate lodash dependencies + */ + +interface ThrottleState { + timeoutId: ReturnType | null; + lastCallTime: number; + lastArgs: unknown[] | null; + lastThis: object | null; + isLeadingInvoked: boolean; +} + +interface DebounceState { + timeoutId: ReturnType | null; + lastCallTime: number; + isFirstCall: boolean; +} + +// WeakMap to store per-instance throttle state +const throttleStateMap = new WeakMap>(); + +// WeakMap to store per-instance debounce state +const debounceStateMap = new WeakMap>(); + +/** + * Throttle decorator - limits function calls to once per specified time period + * @param wait - Time in milliseconds to wait between calls + * @param options - Optional configuration for leading/trailing execution + */ +export const throttle = ( + wait: number, + options: { leading?: boolean; trailing?: boolean } = {}, +): MethodDecorator => { + // Validate wait parameter + if (wait < 0 || !Number.isFinite(wait)) { + throw new Error('Wait must be a positive finite number'); + } + + const leading = options.leading !== false; + const trailing = options.trailing !== false; + + return ( + _target: unknown, + propertyKey: string | symbol, + descriptor: PropertyDescriptor, + ): PropertyDescriptor => { + const originalMethod = descriptor.value as (...args: unknown[]) => void; + + descriptor.value = function (this: object, ...args: unknown[]): void { + // Get or create state map for this instance + let stateMap = throttleStateMap.get(this); + if (!stateMap) { + stateMap = new Map(); + throttleStateMap.set(this, stateMap); + } + + // Get or create state for this method + let state = stateMap.get(propertyKey); + if (!state) { + state = { + timeoutId: null, + lastCallTime: 0, + lastArgs: null, + lastThis: null, + isLeadingInvoked: false, + }; + stateMap.set(propertyKey, state); + } + + const now = new Date().getTime(); + const remaining = wait - (now - state.lastCallTime); + + // Clear timeout if we're outside the throttle window + if (remaining <= 0 || remaining > wait) { + if (state.timeoutId) { + clearTimeout(state.timeoutId); + state.timeoutId = null; + } + + // Reset leading invoked flag since we're in a new throttle window + state.isLeadingInvoked = false; + } + + // Determine if we should invoke now + const shouldInvokeNow = + leading && !state.isLeadingInvoked && (remaining <= 0 || remaining > wait); + + if (shouldInvokeNow) { + state.lastCallTime = now; + state.isLeadingInvoked = true; + try { + originalMethod.apply(this, args); + } catch (error) { + // Re-throw but ensure state is consistent + throw error; + } + // Don't store args for trailing if we just executed on leading edge + // and there's no active timer (meaning this is the first call) + if (state.timeoutId) { + state.lastArgs = args; + state.lastThis = this; + } + } else { + // Store args for trailing execution + state.lastArgs = args; + state.lastThis = this; + } + + // Set up trailing call if needed + if (trailing && !state.timeoutId) { + const delay = shouldInvokeNow + ? wait + : remaining > 0 && remaining < wait + ? remaining + : wait; + state.timeoutId = setTimeout(() => { + const hasArgs = state.lastArgs !== null; + const shouldInvokeTrailing = hasArgs && trailing; + + // Reset state + state.timeoutId = null; + state.lastCallTime = hasArgs ? new Date().getTime() : 0; + state.isLeadingInvoked = false; + + if (shouldInvokeTrailing && state.lastArgs && state.lastThis) { + try { + originalMethod.apply(state.lastThis, state.lastArgs); + } catch (error) { + // Still clean up state even if method throws + state.lastArgs = null; + state.lastThis = null; + throw error; + } + } + + state.lastArgs = null; + state.lastThis = null; + }, delay); + } + }; + + return descriptor; + }; +}; + +/** + * Debounce decorator - delays function execution until after wait milliseconds + * have elapsed since the last time it was invoked + * @param wait - Time in milliseconds to delay execution + * @param options - Optional configuration for leading execution + */ +export const debounce = ( + wait: number, + options: { leading?: boolean } = {}, +): MethodDecorator => { + // Validate wait parameter + if (wait < 0 || !Number.isFinite(wait)) { + throw new Error('Wait must be a positive finite number'); + } + + const leading = !!options.leading; + + return ( + _target: unknown, + propertyKey: string | symbol, + descriptor: PropertyDescriptor, + ): PropertyDescriptor => { + const originalMethod = descriptor.value as (...args: unknown[]) => void; + + descriptor.value = function (this: object, ...args: unknown[]): void { + // Get or create state map for this instance + let stateMap = debounceStateMap.get(this); + if (!stateMap) { + stateMap = new Map(); + debounceStateMap.set(this, stateMap); + } + + // Get or create state for this method + let state = stateMap.get(propertyKey); + if (!state) { + state = { + timeoutId: null, + lastCallTime: 0, + isFirstCall: true, + }; + stateMap.set(propertyKey, state); + } + + // Determine if we should call immediately (leading edge) + const callNow = leading && state.isFirstCall; + + // Clear existing timeout + if (state.timeoutId) { + clearTimeout(state.timeoutId); + state.timeoutId = null; + } + + if (callNow) { + // Execute immediately on leading edge + state.isFirstCall = false; + state.lastCallTime = new Date().getTime(); + try { + originalMethod.apply(this, args); + } catch (error) { + // Re-throw but ensure state is consistent + throw error; + } + } + + // Set up the debounced call + state.timeoutId = setTimeout(() => { + state.timeoutId = null; + state.lastCallTime = new Date().getTime(); + state.isFirstCall = true; // Reset for next sequence + + if (!leading) { + // Only execute on trailing edge if leading is false + try { + originalMethod.apply(this, args); + } catch (error) { + // Still clean up state even if method throws + throw error; + } + } + }, wait); + }; + + return descriptor; + }; +};