-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Fix crash when constructing jest.fn() via Reflect.construct #31164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -86,6 +86,7 @@ inline To tryJSDynamicCast(JSC::WriteBarrier<WriteBarrierT>& from) | |
| } | ||
|
|
||
| JSC_DECLARE_HOST_FUNCTION(jsMockFunctionCall); | ||
| JSC_DECLARE_HOST_FUNCTION(jsMockFunctionConstruct); | ||
| JSC_DECLARE_CUSTOM_GETTER(jsMockFunctionGetter_protoImpl); | ||
| JSC_DECLARE_CUSTOM_GETTER(jsMockFunctionGetter_mock); | ||
| JSC_DECLARE_HOST_FUNCTION(jsMockFunctionGetter_mockGetLastCall); | ||
|
|
@@ -462,7 +463,7 @@ class JSMockFunction : public JSC::InternalFunction { | |
| } | ||
|
|
||
| JSMockFunction(JSC::VM& vm, JSC::Structure* structure, CallbackKind wrapKind) | ||
| : Base(vm, structure, jsMockFunctionCall, jsMockFunctionCall) | ||
| : Base(vm, structure, jsMockFunctionCall, jsMockFunctionConstruct) | ||
| { | ||
| initMock(); | ||
| } | ||
|
|
@@ -826,7 +827,7 @@ static JSValue createMockResult(JSC::VM& vm, Zig::GlobalObject* globalObject, co | |
| return result; | ||
| } | ||
|
|
||
| JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) | ||
| static JSC::EncodedJSValue jsMockFunctionCallOrConstruct(JSGlobalObject* lexicalGlobalObject, CallFrame* callframe, bool isConstruct) | ||
| { | ||
| Zig::GlobalObject* globalObject = uncheckedDowncast<Zig::GlobalObject>(lexicalGlobalObject); | ||
| auto& vm = JSC::getVM(globalObject); | ||
|
|
@@ -839,6 +840,14 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje | |
|
|
||
| JSC::ArgList args = JSC::ArgList(callframe); | ||
| JSValue thisValue = callframe->thisValue(); | ||
| if (isConstruct) { | ||
| thisValue = JSC::constructEmptyObject(globalObject); | ||
| } | ||
|
Comment on lines
+843
to
+845
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟣 Pre-existing (non-blocking): Extended reasoning...What the issue isIn Jest, Code pathIn Why nothing currently catches thisExisting tests in Step-by-step proof
Why this is pre-existingThis gap predates the PR entirely. Before this change, Suggested fix (follow-up)In the if (isConstruct) {
thisValue = JSC::constructEmptyObject(globalObject);
JSC::JSArray* instances = fn->instances.get();
if (instances) {
instances->push(globalObject, thisValue);
RETURN_IF_EXCEPTION(scope, {});
} else {
JSC::ObjectInitializationScope object(vm);
instances = JSC::JSArray::tryCreateUninitializedRestricted(
object,
globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous),
1);
instances->initializeIndex(object, 0, thisValue);
fn->instances.set(vm, fn, instances);
}
}(Note: Jest actually pushes |
||
| auto encodeReturn = [&](JSValue returnValue) -> JSC::EncodedJSValue { | ||
| if (isConstruct && !returnValue.isObject()) [[unlikely]] | ||
| return JSValue::encode(thisValue); | ||
| return JSValue::encode(returnValue); | ||
| }; | ||
| JSC::JSArray* argumentsArray = nullptr; | ||
| { | ||
| JSC::ObjectInitializationScope object(vm); | ||
|
|
@@ -954,16 +963,16 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje | |
| fn->returnValues.set(vm, fn, returnValuesArray); | ||
| } | ||
|
|
||
| return JSValue::encode(returnValue); | ||
| return encodeReturn(returnValue); | ||
| } | ||
| case JSMockImplementation::Kind::ReturnValue: { | ||
| JSValue returnValue = impl->underlyingValue.get(); | ||
| setReturnValue(createMockResult(vm, globalObject, "return"_s, returnValue)); | ||
| return JSValue::encode(returnValue); | ||
| return encodeReturn(returnValue); | ||
| } | ||
| case JSMockImplementation::Kind::ReturnThis: { | ||
| setReturnValue(createMockResult(vm, globalObject, "return"_s, thisValue)); | ||
| return JSValue::encode(thisValue); | ||
| return encodeReturn(thisValue); | ||
| } | ||
| case JSMockImplementation::Kind::RejectedValue: { | ||
| JSValue rejectedPromise = JSC::JSPromise::rejectedPromise(globalObject, impl->underlyingValue.get()); | ||
|
|
@@ -978,7 +987,17 @@ JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObje | |
| } | ||
|
|
||
| setReturnValue(createMockResult(vm, globalObject, "return"_s, jsUndefined())); | ||
| return JSValue::encode(jsUndefined()); | ||
| return encodeReturn(jsUndefined()); | ||
| } | ||
|
|
||
| JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) | ||
| { | ||
| return jsMockFunctionCallOrConstruct(lexicalGlobalObject, callframe, false); | ||
| } | ||
|
|
||
| JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) | ||
| { | ||
| return jsMockFunctionCallOrConstruct(lexicalGlobalObject, callframe, true); | ||
| } | ||
|
|
||
| void JSMockFunctionPrototype::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import { describe, expect, jest, spyOn, test } from "bun:test"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit: Per CLAUDE.md (root and Extended reasoning...The repo's contributor guidelines explicitly call out test placement. Root The existing canonical file is There's also a concrete benefit beyond convention: Step-by-step:
The only minor friction is that the new file uses TypeScript annotations ( Fix: Move the |
||
|
|
||
| describe("Reflect.construct on mock functions", () => { | ||
| test("jest.fn() with no implementation", () => { | ||
| const fn = jest.fn(); | ||
| const result = Reflect.construct(fn, []); | ||
| expect(typeof result).toBe("object"); | ||
| expect(result).not.toBe(fn); | ||
| }); | ||
|
|
||
| test("jest.fn() with implementation returning a primitive", () => { | ||
| const fn = jest.fn(() => 42); | ||
| const result = Reflect.construct(fn, []); | ||
| expect(typeof result).toBe("object"); | ||
| }); | ||
|
|
||
| test("jest.fn() with implementation returning an object", () => { | ||
| const obj = { a: 1 }; | ||
| const fn = jest.fn(() => obj); | ||
| const result = Reflect.construct(fn, []); | ||
| expect(result).toBe(obj); | ||
| }); | ||
|
|
||
| test("jest.fn().mockReturnValue(primitive)", () => { | ||
| const fn = jest.fn().mockReturnValue(42); | ||
| const result = Reflect.construct(fn, []); | ||
| expect(typeof result).toBe("object"); | ||
| }); | ||
|
|
||
| test("jest.fn().mockReturnThis()", () => { | ||
| const fn = jest.fn().mockReturnThis(); | ||
| const result = Reflect.construct(fn, []); | ||
| expect(typeof result).toBe("object"); | ||
| }); | ||
|
|
||
| test("spyOn an undefined property", () => { | ||
| const obj: Record<string, unknown> = {}; | ||
| const spy = spyOn(obj, "x" as never); | ||
| const result = Reflect.construct(spy, []); | ||
| expect(typeof result).toBe("object"); | ||
| }); | ||
|
|
||
| test("with explicit newTarget", () => { | ||
| const fn = jest.fn(); | ||
| const result = Reflect.construct(fn, [], function () {}); | ||
| expect(typeof result).toBe("object"); | ||
| }); | ||
|
|
||
| test("implementation receives constructed this", () => { | ||
| const fn = jest.fn(function (this: { x: number }) { | ||
| this.x = 1; | ||
| }); | ||
| const result = Reflect.construct(fn, []); | ||
| expect(result).toEqual({ x: 1 }); | ||
| }); | ||
|
|
||
| test("new on jest.fn() returns an object", () => { | ||
| const fn = jest.fn(); | ||
| const result = new (fn as any)(); | ||
| expect(typeof result).toBe("object"); | ||
| expect(result).not.toBe(fn); | ||
| }); | ||
|
|
||
| test("regular call preserves this", () => { | ||
| const fn = jest.fn(function (this: unknown) { | ||
| return this; | ||
| }); | ||
| const obj = { fn }; | ||
| expect(obj.fn()).toBe(obj); | ||
| expect(fn.call(123 as any)).toBe(123); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 The synthesized
thisis created withconstructEmptyObject(globalObject), which always usesObject.prototypeand ignorescallframe->newTarget(). As a resultnew (jest.fn())() instanceof fnandReflect.construct(jest.fn(), [], Foo) instanceof Fooare bothfalse, whereas in Jest (and per ordinary [[Construct]]) they aretrue. Consider deriving the prototype fromnewTarget(e.g. viaInternalFunction::createSubclassStructure/getFunctionRealm, as done inNodeVMScript.cppand other native constructors here).Extended reasoning...
What the bug is
In the new construct path, the freshly-allocated
thisis created with the single-argument overloadJSC::constructEmptyObject(globalObject). That overload returns aJSFinalObjectwhose [[Prototype]] isglobalObject->objectPrototype()— i.e. plainObject.prototype. It never consultscallframe->newTarget().Per the ECMAScript [[Construct]] internal method (step OrdinaryCreateFromConstructor), the new object's [[Prototype]] must come from
newTarget.prototype. The PR description explicitly says the goal is to "match ordinary [[Construct]] semantics and Jest's behavior", but for the prototype chain it matches neither: in Jest, mock functions are real JS functions, sonew fn()goes through ordinary [[Construct]] and the instance inherits fromfn.prototype.Code path that triggers it
callframe->newTarget()is available here (this is the native-construct entry point) but is never read. Whether the call comes fromnew fn(),Reflect.construct(fn, []), orReflect.construct(fn, [], Foo), the resulting object's prototype is the same:Object.prototype.Why nothing else prevents it
The PR's own "with explicit newTarget" test only asserts
typeof result === 'object', which a plain{}satisfies. It would fail if it assertedresult instanceof newTarget— which is the observable contractnewTargetexists to provide. None of the other added tests check the prototype chain either.Step-by-step proof
In each case,
constructEmptyObject(globalObject)produced{ __proto__: Object.prototype }, soOrdinaryHasInstancewalks the chain, never findsfn.prototype/Foo.prototype, and returnsfalse.Impact
Not a crash and strictly better than the pre-PR behavior (which crashed/returned
undefined), but it's an observable correctness divergence in the exact lines this PR adds. Tests that mock a constructor and then assertexpect(new Mock()).toBeInstanceOf(Mock)— which pass under Jest — will fail. Methods placed onfn.prototypeare unreachable from constructed instances.How to fix
Use the same pattern this codebase already uses in ~15 native constructors (e.g.
NodeVMScript.cpp,JSX509Certificate.cpp,NapiClass.cpp): readcallframe->newTarget(), resolve its function realm, derive a structure viaInternalFunction::createSubclassStructure(falling back to a default ifnewTarget.prototypeisn't an object), and pass that structure toconstructEmptyObject. Thennew fn() instanceof fnandReflect.construct(fn, [], Foo) instanceof Foowill both hold, matching Jest.