mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-22 18:30:09 +00:00
feat: replace helpful-decorators and thus lodash with custom implementation to save a lot of space
This commit is contained in:
parent
6bed63b52d
commit
4b05f21285
10 changed files with 596 additions and 40 deletions
33
package-lock.json
generated
33
package-lock.json
generated
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
359
src/app/util/decorators.spec.ts
Normal file
359
src/app/util/decorators.spec.ts
Normal 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
231
src/app/util/decorators.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue