diff --git a/docs/pages/function/_meta.json b/docs/pages/function/_meta.json index 24ce8466..158ca214 100644 --- a/docs/pages/function/_meta.json +++ b/docs/pages/function/_meta.json @@ -4,6 +4,7 @@ "debounce": "debounce", "noop": "noop", "not": "not", + "once": "once", "pipe": "pipe", "sleep": "sleep", "throttle": "throttle", diff --git a/docs/pages/function/once.mdx b/docs/pages/function/once.mdx new file mode 100644 index 00000000..302138c7 --- /dev/null +++ b/docs/pages/function/once.mdx @@ -0,0 +1,35 @@ +# once + +Creates a function that is restricted to invoking the given function once. +Repeat calls to the function return the value of the first invocation. + +### Import + +```typescript copy +import { once } from '@fullstacksjs/toolbox'; +``` + +### Signature + +```typescript copy +function once any>(fn: T): T {} +``` + +### Examples + +```typescript copy +const initialize = once(() => { + console.log('Initializing...'); + return { initialized: true }; +}); + +initialize(); // Logs: 'Initializing...' and returns { initialized: true } +initialize(); // Returns { initialized: true } without logging +``` + +```typescript copy +const greet = once((name: string) => `Hello, ${name}!`); + +greet('Alice'); // 'Hello, Alice!' +greet('Bob'); // 'Hello, Alice!' (cached result) +``` diff --git a/src/function/index.ts b/src/function/index.ts index 9288199e..1479c2c0 100644 --- a/src/function/index.ts +++ b/src/function/index.ts @@ -4,6 +4,7 @@ export { debounce } from './debounce.ts'; export { formatSeconds } from './formatSeconds.ts'; export { noop } from './noop.ts'; export { not } from './not.ts'; +export { once } from './once.ts'; export { pipe } from './pipe.ts'; export { sleep } from './sleep.ts'; export { throttle } from './throttle.ts'; diff --git a/src/function/once.spec.ts b/src/function/once.spec.ts new file mode 100644 index 00000000..d1593d42 --- /dev/null +++ b/src/function/once.spec.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { once } from './once'; + +describe('once', () => { + it('should execute the function only once', () => { + const fn = vi.fn(() => 'result'); + const onceFn = once(fn); + + expect(onceFn()).toBe('result'); + expect(onceFn()).toBe('result'); + expect(onceFn()).toBe('result'); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('should return the same result on subsequent calls', () => { + let counter = 0; + const fn = once(() => ++counter); + + expect(fn()).toBe(1); + expect(fn()).toBe(1); + expect(fn()).toBe(1); + }); + + it('should work with functions that have parameters', () => { + const fn = vi.fn((a: number, b: number) => a + b); + const onceFn = once(fn); + + expect(onceFn(2, 3)).toBe(5); + expect(onceFn(5, 7)).toBe(5); // Returns cached result + expect(onceFn(10, 20)).toBe(5); // Returns cached result + + expect(fn).toHaveBeenCalledOnce(); + expect(fn).toHaveBeenCalledWith(2, 3); + }); + + it('should preserve the function context (this)', () => { + const obj = { + value: 42, + getValue: once(function getValue(this: { value: number }) { + return this.value; + }), + }; + + expect(obj.getValue()).toBe(42); + expect(obj.getValue()).toBe(42); + }); + + it('should work with functions that return undefined', () => { + const fn = vi.fn(() => undefined); + const onceFn = once(fn); + + expect(onceFn()).toBeUndefined(); + expect(onceFn()).toBeUndefined(); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('should work with functions that return objects', () => { + const obj = { initialized: true }; + const fn = vi.fn(() => obj); + const onceFn = once(fn); + + const result1 = onceFn(); + const result2 = onceFn(); + + expect(result1).toBe(obj); + expect(result2).toBe(obj); + expect(result1).toBe(result2); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('should work with async functions', async () => { + const fn = vi.fn(async () => 'async result'); + const onceFn = once(fn); + + const result1 = await onceFn(); + const result2 = await onceFn(); + + expect(result1).toBe('async result'); + expect(result2).toBe('async result'); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('should cache the first result even if it is falsy', () => { + const fn = vi.fn(() => 0); + const onceFn = once(fn); + + expect(onceFn()).toBe(0); + expect(onceFn()).toBe(0); + + expect(fn).toHaveBeenCalledOnce(); + }); + + it('should handle functions that throw errors', () => { + const fn = vi.fn(() => { + throw new Error('Test error'); + }); + const onceFn = once(fn); + + expect(() => onceFn()).toThrow('Test error'); + expect(() => onceFn()).not.toThrow(); // Returns cached undefined + expect(fn).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/function/once.ts b/src/function/once.ts new file mode 100644 index 00000000..84ef0051 --- /dev/null +++ b/src/function/once.ts @@ -0,0 +1,45 @@ +/** + * Creates a function that is restricted to invoking the given function once. + * Repeat calls to the function return the value of the first invocation. + * + * @template T type of the function being wrapped. + * @param {T} fn The function to restrict. + * @returns {T} Returns the new restricted function. + * + * @example + * + * const initialize = once(() => { + * console.log('Initializing...'); + * return { initialized: true }; + * }); + * + * initialize(); // Logs: 'Initializing...' and returns { initialized: true } + * initialize(); // Returns { initialized: true } without logging + * initialize(); // Returns { initialized: true } without logging + * + * @example + * + * const greet = once((name: string) => { + * console.log(`Hello, ${name}!`); + * return `Welcome ${name}`; + * }); + * + * greet('Alice'); // Logs: 'Hello, Alice!' and returns 'Welcome Alice' + * greet('Bob'); // Returns 'Welcome Alice' (uses cached result) + */ +export function once any>(fn: T): T { + let called = false; + let result: ReturnType; + + return function onceWrapper( + this: unknown, + ...args: Parameters + ): ReturnType { + if (!called) { + called = true; + result = fn.apply(this, args) as ReturnType; + return result; + } + return result; + } as T; +}