Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/pages/function/_meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"debounce": "debounce",
"noop": "noop",
"not": "not",
"once": "once",
"pipe": "pipe",
"sleep": "sleep",
"throttle": "throttle",
Expand Down
35 changes: 35 additions & 0 deletions docs/pages/function/once.mdx
Original file line number Diff line number Diff line change
@@ -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<T extends (...args: any[]) => 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)
```
1 change: 1 addition & 0 deletions src/function/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
106 changes: 106 additions & 0 deletions src/function/once.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the wrapped function throws an error on first call, the result variable remains undefined and subsequent calls return undefined instead of re-throwing the error. This behavior may be unexpected. Consider either: (1) storing the thrown error and re-throwing it on subsequent calls, or (2) not marking the function as called when an error occurs, allowing it to retry. The current implementation silently swallows errors after the first call.

Suggested change
expect(() => onceFn()).not.toThrow(); // Returns cached undefined
expect(() => onceFn()).toThrow('Test error'); // Rethrows cached error without calling fn again

Copilot uses AI. Check for mistakes.
expect(fn).toHaveBeenCalledOnce();
});
});
45 changes: 45 additions & 0 deletions src/function/once.ts
Original file line number Diff line number Diff line change
@@ -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<T extends (...args: any[]) => any>(fn: T): T {
let called = false;
let result: ReturnType<T>;

return function onceWrapper(
this: unknown,
...args: Parameters<T>
): ReturnType<T> {
if (!called) {
called = true;
result = fn.apply(this, args) as ReturnType<T>;
return result;
}
return result;
} as T;
}