Skip to content

Commit 072b6dd

Browse files
authored
fix(core): mock object properties lazily (#1415)
1 parent aaeed91 commit 072b6dd

3 files changed

Lines changed: 279 additions & 26 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { describe, expect, it, rs } from '@rstest/core';
2+
3+
rs.mock('@rsbuild/core', { mock: true });
4+
5+
describe('rs.mock with { mock: true } for large modules', () => {
6+
it('creates mocks lazily for nested exports', async () => {
7+
const rsbuild = await import('@rsbuild/core');
8+
9+
expect(rs.isMockFunction(rsbuild.createRsbuild)).toBe(true);
10+
expect(rsbuild.createRsbuild()).toBeUndefined();
11+
});
12+
});

packages/core/src/runtime/api/mockObject.ts

Lines changed: 129 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ interface GlobalConstructors {
2727
RegExp: typeof RegExp;
2828
}
2929

30+
interface PropertySnapshot {
31+
props: Key[];
32+
descriptors: Map<Key, PropertyDescriptor>;
33+
isModule: boolean;
34+
}
35+
3036
/**
3137
* Get the type name of a value using Object.prototype.toString
3238
*/
@@ -52,6 +58,10 @@ function isFunction(value: unknown): value is (...args: any[]) => any {
5258
return typeof value === 'function';
5359
}
5460

61+
function isModuleObject(value: Record<Key, any>): boolean {
62+
return getTypeName(value) === 'Module' || value.__esModule;
63+
}
64+
5565
/**
5666
* Check if a property is a built-in readonly property (e.g., function's name, length)
5767
*/
@@ -149,13 +159,36 @@ function getEnumerableProperties(
149159
return [...props];
150160
}
151161

162+
function getPropertyDescriptor(
163+
obj: any,
164+
prop: Key,
165+
): PropertyDescriptor | undefined {
166+
let current = obj;
167+
const visited = new WeakSet();
168+
169+
while (current) {
170+
if (visited.has(current)) {
171+
return undefined;
172+
}
173+
visited.add(current);
174+
175+
const descriptor = Object.getOwnPropertyDescriptor(current, prop);
176+
if (descriptor) {
177+
return descriptor;
178+
}
179+
current = Object.getPrototypeOf(current);
180+
}
181+
182+
return undefined;
183+
}
184+
152185
/**
153186
* Deeply mock an object
154187
*
155188
* Core algorithm:
156189
* 1. Iterate over all properties of the object
157190
* 2. Functions → replace with mock functions (automock) or wrap as spy (autospy)
158-
* 3. Plain objects → process recursively
191+
* 3. Plain objects → process properties lazily
159192
* 4. Arrays → empty in automock mode, process elements recursively in autospy mode
160193
* 5. Primitive values → keep unchanged
161194
* 6. Use WeakMap to track processed objects and avoid circular references
@@ -170,8 +203,44 @@ export function mockObject<T extends Record<Key, any>>(
170203

171204
// Track processed object references to prevent infinite recursion from circular references
172205
const processedRefs = new WeakMap();
173-
// Deferred assignment queue for handling circular references
174-
const deferredAssignments: (() => void)[] = [];
206+
const snapshotRefs = new WeakMap();
207+
208+
const snapshotProperties = (value: any): PropertySnapshot | undefined => {
209+
if (
210+
!isSpyMode ||
211+
value === null ||
212+
value === undefined ||
213+
(typeof value !== 'object' && typeof value !== 'function') ||
214+
(!isPlainObject(value) && !isFunction(value))
215+
) {
216+
return undefined;
217+
}
218+
219+
if (snapshotRefs.has(value)) {
220+
return snapshotRefs.get(value);
221+
}
222+
223+
const props = getEnumerableProperties(value, globalConstructors);
224+
const descriptors = new Map<Key, PropertyDescriptor>();
225+
const snapshot: PropertySnapshot = {
226+
props,
227+
descriptors,
228+
isModule: !isFunction(value) && isModuleObject(value),
229+
};
230+
snapshotRefs.set(value, snapshot);
231+
232+
const isModule = snapshot.isModule;
233+
for (const prop of props) {
234+
const descriptor = isModule
235+
? Object.getOwnPropertyDescriptor(value, prop)
236+
: getPropertyDescriptor(value, prop);
237+
if (descriptor) {
238+
descriptors.set(prop, descriptor);
239+
}
240+
}
241+
242+
return snapshot;
243+
};
175244

176245
/**
177246
* Create a mock instance for a function
@@ -192,7 +261,7 @@ export function mockObject<T extends Record<Key, any>>(
192261
/**
193262
* Process a single value and return the mocked value
194263
*/
195-
const processValue = (value: any): any => {
264+
const processValue = (value: any, snapshot?: PropertySnapshot): any => {
196265
// Return primitive values as-is
197266
if (value === null || value === undefined) {
198267
return value;
@@ -216,7 +285,7 @@ export function mockObject<T extends Record<Key, any>>(
216285
if (isFunction(value)) {
217286
const mock = createFunctionMock(value);
218287
// Functions may also have properties, process them recursively
219-
processProperties(value, mock);
288+
processProperties(value, mock, snapshot);
220289
return mock;
221290
}
222291

@@ -227,14 +296,14 @@ export function mockObject<T extends Record<Key, any>>(
227296
return [];
228297
}
229298
// autospy mode: process array elements recursively
230-
return value.map(processValue);
299+
return value.map((item) => processValue(item));
231300
}
232301

233302
// Handle plain objects
234303
if (isPlainObject(value)) {
235304
const result: Record<Key, any> = {};
236305
processedRefs.set(value, result);
237-
processProperties(value, result);
306+
processProperties(value, result, snapshot);
238307
return result;
239308
}
240309

@@ -248,17 +317,20 @@ export function mockObject<T extends Record<Key, any>>(
248317
const processProperties = (
249318
source: Record<Key, any>,
250319
target: Record<Key, any>,
320+
snapshot?: PropertySnapshot,
251321
): void => {
252-
const props = getEnumerableProperties(source, globalConstructors);
253-
const isModule = getTypeName(source) === 'Module' || source.__esModule;
322+
const props =
323+
snapshot?.props ?? getEnumerableProperties(source, globalConstructors);
324+
const isModule = snapshot?.isModule ?? isModuleObject(source);
254325

255326
for (const prop of props) {
256327
// Skip built-in readonly properties
257328
if (isBuiltinReadonly(source, prop)) {
258329
continue;
259330
}
260331

261-
const descriptor = Object.getOwnPropertyDescriptor(source, prop);
332+
const descriptor =
333+
snapshot?.descriptors.get(prop) ?? getPropertyDescriptor(source, prop);
262334
if (!descriptor) continue;
263335

264336
// Handle getter/setter (non-module)
@@ -281,22 +353,58 @@ export function mockObject<T extends Record<Key, any>>(
281353
continue;
282354
}
283355

284-
const value = source[prop];
285-
286-
// Check for circular references
287-
if (value && typeof value === 'object' && processedRefs.has(value)) {
288-
// Defer assignment until all objects are processed
289-
deferredAssignments.push(() => {
290-
target[prop] = processedRefs.get(value);
291-
});
356+
const hasValue = 'value' in descriptor;
357+
const value = hasValue ? descriptor.value : undefined;
358+
const sourceValue =
359+
hasValue && isSpyMode && Array.isArray(value) ? value.slice() : value;
360+
const sourceSnapshot = hasValue ? snapshotProperties(value) : undefined;
361+
const recursiveFunctionMock =
362+
hasValue && isFunction(source) && value === source ? target : undefined;
363+
const canInstallLazyProperty = !(
364+
isFunction(target) &&
365+
prop === 'prototype' &&
366+
value &&
367+
typeof value === 'object'
368+
);
369+
370+
if (!canInstallLazyProperty) {
371+
try {
372+
target[prop] =
373+
recursiveFunctionMock ?? processValue(sourceValue, sourceSnapshot);
374+
} catch {
375+
// Ignore assignment failures (some properties may be readonly)
376+
}
292377
continue;
293378
}
294379

295-
// Process and assign the value
296380
try {
297-
target[prop] = processValue(value);
381+
let initialized = false;
382+
let mockedValue: any;
383+
const getSourceValue = () => (hasValue ? sourceValue : source[prop]);
384+
385+
Object.defineProperty(target, prop, {
386+
configurable: descriptor.configurable,
387+
enumerable: descriptor.enumerable,
388+
get: () => {
389+
if (!initialized) {
390+
initialized = true;
391+
try {
392+
mockedValue =
393+
recursiveFunctionMock ??
394+
processValue(getSourceValue(), sourceSnapshot);
395+
} catch {
396+
mockedValue = undefined;
397+
}
398+
}
399+
return mockedValue;
400+
},
401+
set: (newValue) => {
402+
initialized = true;
403+
mockedValue = newValue;
404+
},
405+
});
298406
} catch {
299-
// Ignore assignment failures (some properties may be readonly)
407+
// Ignore definition failures
300408
}
301409
}
302410
};
@@ -305,10 +413,5 @@ export function mockObject<T extends Record<Key, any>>(
305413
processedRefs.set(object, mockExports);
306414
processProperties(object, mockExports);
307415

308-
// Execute deferred assignments to resolve circular references
309-
for (const assign of deferredAssignments) {
310-
assign();
311-
}
312-
313416
return mockExports as T;
314417
}

0 commit comments

Comments
 (0)