diff --git a/src/jsc/bindings/JSMockFunction.cpp b/src/jsc/bindings/JSMockFunction.cpp index 1c1c7fe70f6e..11d28401da26 100644 --- a/src/jsc/bindings/JSMockFunction.cpp +++ b/src/jsc/bindings/JSMockFunction.cpp @@ -838,7 +838,11 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje } JSC::ArgList args = JSC::ArgList(callframe); - JSValue thisValue = callframe->thisValue(); + // For `f()` calls where `f` resolves through a scope, JSC passes the resolved + // JSScope as the unconverted `this` argument and relies on the callee's ToThis to + // normalize it. Host functions never run ToThis, so normalize here to avoid leaking + // an engine-internal scope object through mockReturnThis / the contexts array. + JSValue thisValue = callframe->thisValue().toThis(globalObject, JSC::ECMAMode::strict()); JSC::JSArray* argumentsArray = nullptr; { JSC::ObjectInitializationScope object(vm); diff --git a/test/js/bun/test/mock-fn.test.js b/test/js/bun/test/mock-fn.test.js index 7f6a244d9806..72434fed22d5 100644 --- a/test/js/bun/test/mock-fn.test.js +++ b/test/js/bun/test/mock-fn.test.js @@ -238,6 +238,18 @@ describe("mock()", () => { const obj = { fn }; expect(obj.fn()).toBe(obj); }); + test("mockReturnThis on a closure-captured mock does not leak a scope object", () => { + // When a mock is called as a bare identifier captured by a closure, JSC passes the + // enclosing environment record as the unconverted `this`. mockReturnThis must not hand + // that engine-internal scope object back to JavaScript (reading a TDZ binding from it + // surfaces the empty JSValue and crashes the interpreter on a later `typeof`). + const fn = jest.fn().mockReturnThis(); + const capture = () => fn; + expect(capture()).toBe(fn); + const result = fn(); + expect(result).toBeUndefined(); + expect(typeof result === "function").toBe(false); + }); if (isBun) { test("jest.fn(10) return value shorthand", () => { expect(jest.fn(10)()).toBe(10);