-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Fix crash when constructing a jest.fn() mock #30641
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 @@ | |
| } | ||
|
|
||
| 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 @@ | |
| } | ||
|
|
||
| JSMockFunction(JSC::VM& vm, JSC::Structure* structure, CallbackKind wrapKind) | ||
| : Base(vm, structure, jsMockFunctionCall, jsMockFunctionCall) | ||
| : Base(vm, structure, jsMockFunctionCall, jsMockFunctionConstruct) | ||
| { | ||
| initMock(); | ||
| } | ||
|
|
@@ -826,7 +827,8 @@ | |
| return result; | ||
| } | ||
|
|
||
| JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) | ||
| template<bool isConstructCall> | ||
| static JSC::EncodedJSValue jsMockFunctionCallImpl(JSGlobalObject* lexicalGlobalObject, CallFrame* callframe) | ||
| { | ||
| Zig::GlobalObject* globalObject = uncheckedDowncast<Zig::GlobalObject>(lexicalGlobalObject); | ||
| auto& vm = JSC::getVM(globalObject); | ||
|
|
@@ -839,6 +841,39 @@ | |
|
|
||
| JSC::ArgList args = JSC::ArgList(callframe); | ||
| JSValue thisValue = callframe->thisValue(); | ||
|
|
||
| if constexpr (isConstructCall) { | ||
| JSObject* newTargetObject = asObject(callframe->newTarget()); | ||
| JSGlobalObject* functionGlobalObject = getFunctionRealm(globalObject, newTargetObject); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| Structure* baseStructure = functionGlobalObject->objectStructureForObjectConstructor(); | ||
| Structure* structure = newTargetObject == fn ? baseStructure : InternalFunction::createSubclassStructure(globalObject, newTargetObject, baseStructure); | ||
|
Check failure on line 850 in src/jsc/bindings/JSMockFunction.cpp
|
||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| thisValue = constructEmptyObject(vm, structure); | ||
|
|
||
| JSC::JSArray* instancesArray = fn->instances.get(); | ||
| if (instancesArray) { | ||
| instancesArray->push(globalObject, thisValue); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| } else { | ||
| JSC::ObjectInitializationScope object(vm); | ||
| instancesArray = JSC::JSArray::tryCreateUninitializedRestricted( | ||
| object, | ||
| globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), | ||
| 1); | ||
| instancesArray->initializeIndex(object, 0, thisValue); | ||
| fn->instances.set(vm, fn, instancesArray); | ||
| } | ||
|
Check warning on line 866 in src/jsc/bindings/JSMockFunction.cpp
|
||
|
Comment on lines
+854
to
+866
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. 🟡 In Jest, Extended reasoning...What the bug isJest's mock-function wrapper (in mockState.instances.push(this);
mockState.contexts.push(this);
mockState.calls.push(args);All three arrays therefore grow in lockstep and How it manifests / step-by-step proofconst fn = jest.fn();
new fn(); // call 0: construct
fn(); // call 1: regular
new fn(); // call 2: constructJest:
Bun after this PR:
Any Jest-compatible test that indexes these arrays in parallel (e.g. Why existing code doesn't prevent itThe Impact
Note that pre-PR, How to fixEither:
|
||
| } | ||
|
|
||
| auto constructorResult = [&](JSValue returnValue) -> JSValue { | ||
| if constexpr (isConstructCall) { | ||
| if (!returnValue.isObject()) | ||
| return thisValue; | ||
| } | ||
| return returnValue; | ||
| }; | ||
|
|
||
| JSC::JSArray* argumentsArray = nullptr; | ||
| { | ||
| JSC::ObjectInitializationScope object(vm); | ||
|
|
@@ -954,12 +989,12 @@ | |
| fn->returnValues.set(vm, fn, returnValuesArray); | ||
| } | ||
|
|
||
| return JSValue::encode(returnValue); | ||
| return JSValue::encode(constructorResult(returnValue)); | ||
| } | ||
| case JSMockImplementation::Kind::ReturnValue: { | ||
| JSValue returnValue = impl->underlyingValue.get(); | ||
| setReturnValue(createMockResult(vm, globalObject, "return"_s, returnValue)); | ||
| return JSValue::encode(returnValue); | ||
| return JSValue::encode(constructorResult(returnValue)); | ||
| } | ||
| case JSMockImplementation::Kind::ReturnThis: { | ||
| setReturnValue(createMockResult(vm, globalObject, "return"_s, thisValue)); | ||
|
|
@@ -978,7 +1013,17 @@ | |
| } | ||
|
|
||
| setReturnValue(createMockResult(vm, globalObject, "return"_s, jsUndefined())); | ||
| return JSValue::encode(jsUndefined()); | ||
| return JSValue::encode(constructorResult(jsUndefined())); | ||
| } | ||
|
|
||
| JSC_DEFINE_HOST_FUNCTION(jsMockFunctionCall, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) | ||
| { | ||
| return jsMockFunctionCallImpl<false>(lexicalGlobalObject, callframe); | ||
| } | ||
|
|
||
| JSC_DEFINE_HOST_FUNCTION(jsMockFunctionConstruct, (JSGlobalObject * lexicalGlobalObject, CallFrame* callframe)) | ||
| { | ||
| return jsMockFunctionCallImpl<true>(lexicalGlobalObject, callframe); | ||
| } | ||
|
|
||
| void JSMockFunctionPrototype::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) | ||
|
|
||
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
newTargetObject == fnfast-path skipscreateSubclassStructureand hard-codesobjectStructureForObjectConstructor()(i.e.Object.prototype), so a user-assignedfn.prototypeis ignored when constructing directly vianew fn()— yet honored viaReflect.construct(fn, [], Other). SincecreateSubclassStructurealready falls back tobaseStructurewhennewTarget.prototypeisn't an object, you can drop the ternary and call it unconditionally.Extended reasoning...
What the bug is
Line 850 uses the standard JSC fast-path idiom:
Structure* structure = newTargetObject == fn ? baseStructure : InternalFunction::createSubclassStructure(globalObject, newTargetObject, baseStructure);This pattern is correct for built-in constructors like
ArrayorMap, where it relies on two invariants: (a) the constructor's.prototypeis non-writable/non-configurable, and (b)baseStructurealready has that.prototypebaked in as its stored prototype. Neither invariant holds forJSMockFunction. As anInternalFunction, it has no default.prototypeown-property (so users can freely assign one), andbaseStructurehere isfunctionGlobalObject->objectStructureForObjectConstructor(), whose stored prototype isObject.prototype— not anything the user set.How it manifests
Assigning
.prototypeto a mock is a documented Jest pattern for mocking ES6 classes (see Jest's ES6 class mocks docs). In Jest, mocks are plain JS functions, sonew fn()followsOrdinaryCreateFromConstructorand honorsfn.prototype. With this PR's fast-path, Bun ignores it.Why nothing prevents it
InternalFunction::createSubclassStructuredoes the right thing — it performsnewTarget->get(vm.propertyNames->prototype)and uses the result as the instance's [[Prototype]], falling back tobaseStructureonly when.prototypeis not an object. The bug is precisely that thenewTargetObject == fnbranch skips this lookup. The new test atmock-fn.test.js:842("Reflect.construct respects new.target prototype") only exercises thenewTarget !== fnbranch, so it passes; there's no test that setsfn.prototypeand constructs directly.Step-by-step proof
new fn()entersjsMockFunctionCallImpl<true>withcallframe->newTarget() === fn.newTargetObject == fnis true, sostructure = baseStructure = objectStructureForObjectConstructor().thisValue = constructEmptyObject(vm, structure)→ an object whose [[Prototype]] isObject.prototype.constructorResultreturnsthisValue.Object.getPrototypeOf(inst) === Object.prototype, andinst.greetisundefined.Contrast with
Reflect.construct(fn, [], class C {}):newTargetObject !== fn, socreateSubclassStructurereadsC.prototypeand the instance correctly inherits from it — the asymmetry confirmed by the new test.Impact
Users mocking classes by assigning
Mock.prototype.method = jest.fn()and then doingnew Mock()will get instances that don't inherit those methods, diverging from Jest.instanceofchecks againstfnwill also fail (inst instanceof fn→falseeven thoughfn.prototypeis set).Fix
Drop the ternary:
createSubclassStructurealready returnsbaseStructurewhennewTarget.prototypeis not an object (the default case for a freshjest.fn()), so this is both a correctness fix and a simplification with negligible cost.