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
21 changes: 14 additions & 7 deletions src/lib/memoized.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Creates a memoized version of a function that caches its result forever.
* Creates a memoized version of a function that caches its successful result forever.
* The function is called once, and subsequent calls return the cached result.
*
* @param fn - Function to memoize (can be sync or async)
Expand Down Expand Up @@ -46,12 +46,19 @@ export function memoized<T>(fn: (() => Promise<T>) | (() => T)) {
// Detect if result is a promise
if (result && typeof (result as any).then === "function") {
isAsync = true;
pending = (result as Promise<T>).then((v) => {
value = v;
cached = true;
pending = null;
return v;
});
pending = (result as Promise<T>).then(
(v) => {
value = v;
cached = true;
pending = null;
return v;
},
(err) => {
// Clear pending on rejection so retries are possible
pending = null;
throw err;
},
);
return pending;
} else {
// Synchronous result
Expand Down
183 changes: 183 additions & 0 deletions test/memoized.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { describe, expect, it } from "vitest";
import { memoized } from "../src/lib/memoized.js";

describe("memoized", () => {
describe("successful memoization", () => {
it("should cache successful sync results", () => {
let callCount = 0;
const fn = memoized(() => {
callCount++;
return "result";
});

const result1 = fn();
const result2 = fn();
const result3 = fn();

expect(result1).toBe("result");
expect(result2).toBe("result");
expect(result3).toBe("result");
expect(callCount).toBe(1); // Function called only once
});

it("should cache successful async results", async () => {
let callCount = 0;
const fn = memoized(async () => {
callCount++;
await new Promise((resolve) => setTimeout(resolve, 10));
return "async-result";
});

const result1 = await fn();
const result2 = await fn();
const result3 = await fn();

expect(result1).toBe("async-result");
expect(result2).toBe("async-result");
expect(result3).toBe("async-result");
expect(callCount).toBe(1); // Function called only once
});

it("should return the same promise while async call is in-flight", async () => {
let callCount = 0;
const fn = memoized(async () => {
callCount++;
await new Promise((resolve) => setTimeout(resolve, 100));
return "result";
});

// Start multiple calls simultaneously
const promise1 = fn();
const promise2 = fn();
const promise3 = fn();

// All should be the same promise
expect(promise1).toBe(promise2);
expect(promise2).toBe(promise3);

const results = await Promise.all([promise1, promise2, promise3]);
expect(results).toEqual(["result", "result", "result"]);
expect(callCount).toBe(1); // Function called only once
});
});

describe("promise rejection handling", () => {
it("should clear pending on promise rejection", async () => {
let callCount = 0;
const fn = memoized(async () => {
callCount++;
if (callCount === 1) {
throw new Error("First call fails");
}
return "success";
});

// First call should fail
await expect(fn()).rejects.toThrow("First call fails");
expect(callCount).toBe(1);

// Second call should retry and succeed
const result = await fn();
expect(result).toBe("success");
expect(callCount).toBe(2);
});

it("should not cache failed values", async () => {
let callCount = 0;
const fn = memoized(async () => {
callCount++;
throw new Error(`Failure ${callCount}`);
});

// Multiple failed calls should each throw
await expect(fn()).rejects.toThrow("Failure 1");
await expect(fn()).rejects.toThrow("Failure 2");
await expect(fn()).rejects.toThrow("Failure 3");

expect(callCount).toBe(3); // Each call retries
});

it("should allow retry after rejection", async () => {
let shouldFail = true;
let callCount = 0;

const fn = memoized(async () => {
callCount++;
if (shouldFail) {
throw new Error("Temporary failure");
}
return "recovered";
});

// First call fails
await expect(fn()).rejects.toThrow("Temporary failure");
expect(callCount).toBe(1);

// Fix the condition
shouldFail = false;

// Retry should succeed
const result = await fn();
expect(result).toBe("recovered");
expect(callCount).toBe(2);

// Subsequent calls should use cached success
const result2 = await fn();
expect(result2).toBe("recovered");
expect(callCount).toBe(2); // No additional calls
});

it("should propagate the original error", async () => {
const customError = new Error("Custom error message");
const fn = memoized(async () => {
throw customError;
});

try {
await fn();
expect.fail("Should have thrown");
} catch (err) {
expect(err).toBe(customError);
expect((err as Error).message).toBe("Custom error message");
}
});
});

describe("mixed scenarios", () => {
it("should handle success after multiple failures", async () => {
let attempts = 0;
const fn = memoized(async () => {
attempts++;
if (attempts < 3) {
throw new Error(`Attempt ${attempts} failed`);
}
return "finally succeeded";
});

await expect(fn()).rejects.toThrow("Attempt 1 failed");
await expect(fn()).rejects.toThrow("Attempt 2 failed");

const result = await fn();
expect(result).toBe("finally succeeded");
expect(attempts).toBe(3);

// Verify success is cached
const result2 = await fn();
expect(result2).toBe("finally succeeded");
expect(attempts).toBe(3); // No additional attempts
});

it("should not interfere with sync function behavior", () => {
let callCount = 0;
const syncFn = memoized(() => {
callCount++;
return callCount;
});

expect(syncFn()).toBe(1);
expect(syncFn()).toBe(1); // Cached
expect(syncFn()).toBe(1); // Cached
expect(callCount).toBe(1);
});
});
});