From 6336c90a807f1d05af9a4c2da2683ad4869e02de Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 1 Jul 2026 23:57:52 +0000 Subject: [PATCH] Fix memoize re-invoking factories that return undefined memoize() used 'typeof memo !== "undefined"' as its cache-hit check, so any factory whose result was undefined was re-invoked on every call. This broke the documented run-once/singleton guarantee for services registered via provides()/run() whose factories perform side effects but return nothing. Track invocation with an explicit flag instead. The flag is set only after the delegate returns, preserving the existing retry-on-throw behavior. Adds unit tests for memoize (undefined results, throw-retry, delegate exposure) and a Container-level regression test. --- src/__tests__/Container.spec.ts | 9 +++++++ src/__tests__/memoize.spec.ts | 44 +++++++++++++++++++++++++++++++++ src/memoize.ts | 7 +++++- 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/memoize.spec.ts diff --git a/src/__tests__/Container.spec.ts b/src/__tests__/Container.spec.ts index f68dc78..e62f48a 100644 --- a/src/__tests__/Container.spec.ts +++ b/src/__tests__/Container.spec.ts @@ -397,6 +397,15 @@ describe("Container", () => { const container: Container<{ TestService: string }> = new Container({} as any); expect(() => container.get("TestService")).toThrowError('Could not find Service for Token "TestService"'); }); + + test("a factory returning undefined is only invoked once", () => { + const factory = jest.fn().mockReturnValue(undefined); + const containerWithService = Container.provides("TestService", factory); + + expect(containerWithService.get("TestService")).toBeUndefined(); + expect(containerWithService.get("TestService")).toBeUndefined(); + expect(factory).toHaveBeenCalledTimes(1); + }); }); describe("when getting the Container Token", () => { diff --git a/src/__tests__/memoize.spec.ts b/src/__tests__/memoize.spec.ts new file mode 100644 index 0000000..98a0156 --- /dev/null +++ b/src/__tests__/memoize.spec.ts @@ -0,0 +1,44 @@ +import { isMemoized, memoize } from "../memoize"; + +describe("memoize", () => { + test("invokes the delegate only once and returns the cached result", () => { + const delegate = jest.fn().mockReturnValue("value"); + const memoized = memoize(delegate); + + expect(memoized()).toBe("value"); + expect(memoized()).toBe("value"); + expect(delegate).toHaveBeenCalledTimes(1); + }); + + test("invokes the delegate only once even when it returns undefined", () => { + const delegate = jest.fn().mockReturnValue(undefined); + const memoized = memoize(delegate); + + expect(memoized()).toBeUndefined(); + expect(memoized()).toBeUndefined(); + expect(delegate).toHaveBeenCalledTimes(1); + }); + + test("does not cache when the delegate throws, allowing a retry", () => { + const delegate = jest + .fn() + .mockImplementationOnce(() => { + throw new Error("first call fails"); + }) + .mockReturnValue("recovered"); + const memoized = memoize(delegate); + + expect(() => memoized()).toThrowError("first call fails"); + expect(memoized()).toBe("recovered"); + expect(delegate).toHaveBeenCalledTimes(2); + }); + + test("exposes the original function via delegate and is detected by isMemoized", () => { + const delegate = () => 42; + const memoized = memoize(delegate); + + expect(memoized.delegate).toBe(delegate); + expect(isMemoized(memoized)).toBe(true); + expect(isMemoized(delegate)).toBe(false); + }); +}); diff --git a/src/memoize.ts b/src/memoize.ts index 43f90a8..39a17d6 100644 --- a/src/memoize.ts +++ b/src/memoize.ts @@ -10,10 +10,15 @@ export function isMemoized(fn: unknown): fn is Memoized { } export function memoize(delegate: Fn): Memoized { + // Track invocation with a flag rather than checking `memo` against `undefined`, so that + // factories which legitimately return `undefined` are still only invoked once. The flag is + // set only after `delegate` returns, preserving the existing behavior of retrying on throw. + let invoked = false; let memo: any; const memoized = function (this: any, ...args: any[]) { - if (typeof memo !== "undefined") return memo; + if (invoked) return memo; memo = delegate.apply(this, args); + invoked = true; return memo; }; memoized.delegate = delegate;