feat: replace helpful-decorators and thus lodash with custom implementation to save a lot of space

This commit is contained in:
Johannes Millan 2025-08-09 22:26:59 +02:00
parent 6bed63b52d
commit 4b05f21285
10 changed files with 596 additions and 40 deletions

33
package-lock.json generated
View file

@ -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,

View file

@ -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",

View file

@ -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',

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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<string> {
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);
});
});
});

231
src/app/util/decorators.ts Normal file
View file

@ -0,0 +1,231 @@
/**
* TypeScript decorators for throttle and debounce
* Simple implementation to eliminate lodash dependencies
*/
interface ThrottleState {
timeoutId: ReturnType<typeof setTimeout> | null;
lastCallTime: number;
lastArgs: unknown[] | null;
lastThis: object | null;
isLeadingInvoked: boolean;
}
interface DebounceState {
timeoutId: ReturnType<typeof setTimeout> | null;
lastCallTime: number;
isFirstCall: boolean;
}
// WeakMap to store per-instance throttle state
const throttleStateMap = new WeakMap<object, Map<string | symbol, ThrottleState>>();
// WeakMap to store per-instance debounce state
const debounceStateMap = new WeakMap<object, Map<string | symbol, DebounceState>>();
/**
* 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;
};
};