@@ -14,7 +14,12 @@ import type {
14
14
import { LegacyFakeTimers , ModernFakeTimers } from '@jest/fake-timers' ;
15
15
import type { Global } from '@jest/types' ;
16
16
import { ModuleMocker } from 'jest-mock' ;
17
- import { installCommonGlobals } from 'jest-util' ;
17
+ import {
18
+ installCommonGlobals ,
19
+ isShreddable ,
20
+ setNotShreddable ,
21
+ shred ,
22
+ } from 'jest-util' ;
18
23
19
24
type Timer = {
20
25
id : number ;
@@ -80,12 +85,13 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
80
85
moduleMocker : ModuleMocker | null ;
81
86
customExportConditions = [ 'node' , 'node-addons' ] ;
82
87
private readonly _configuredExportConditions ?: Array < string > ;
88
+ private _globalProxy : GlobalProxy ;
83
89
84
90
// while `context` is unused, it should always be passed
85
91
constructor ( config : JestEnvironmentConfig , _context : EnvironmentContext ) {
86
92
const { projectConfig} = config ;
87
- this . context = createContext ( ) ;
88
-
93
+ this . _globalProxy = new GlobalProxy ( ) ;
94
+ this . context = createContext ( this . _globalProxy . proxy ( ) ) ;
89
95
const global = runInContext (
90
96
'this' ,
91
97
Object . assign ( this . context , projectConfig . testEnvironmentOptions ) ,
@@ -194,6 +200,8 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
194
200
config : projectConfig ,
195
201
global,
196
202
} ) ;
203
+
204
+ this . _globalProxy . envSetupCompleted ( ) ;
197
205
}
198
206
199
207
// eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -206,9 +214,29 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
206
214
if ( this . fakeTimersModern ) {
207
215
this . fakeTimersModern . dispose ( ) ;
208
216
}
209
- this . context = null ;
217
+
218
+ if ( this . context ) {
219
+ // source-map-support keeps memory leftovers in `Error.prepareStackTrace`
220
+ runInContext ( "Error.prepareStackTrace = () => '';" , this . context ) ;
221
+
222
+ // remove any leftover listeners that may hold references to sizable memory
223
+ this . context . process . removeAllListeners ( ) ;
224
+ const cluster = runInContext (
225
+ "require('node:cluster')" ,
226
+ Object . assign ( this . context , {
227
+ require :
228
+ // get native require instead of webpack's
229
+ // @ts -expect-error https://webpack.js.org/api/module-variables/#__non_webpack_require__-webpack-specific
230
+ __non_webpack_require__ ,
231
+ } ) ,
232
+ ) ;
233
+ cluster . removeAllListeners ( ) ;
234
+
235
+ this . context = null ;
236
+ }
210
237
this . fakeTimers = null ;
211
238
this . fakeTimersModern = null ;
239
+ this . _globalProxy . clear ( ) ;
212
240
}
213
241
214
242
exportConditions ( ) : Array < string > {
@@ -221,3 +249,116 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
221
249
}
222
250
223
251
export const TestEnvironment = NodeEnvironment ;
252
+
253
+ /**
254
+ * Creates a new empty global object and wraps it with a {@link Proxy}.
255
+ *
256
+ * The purpose is to register any property set on the global object,
257
+ * and {@link #shred} them at environment teardown, to clean up memory and
258
+ * prevent leaks.
259
+ */
260
+ class GlobalProxy implements ProxyHandler < typeof globalThis > {
261
+ private global : typeof globalThis = Object . create (
262
+ Object . getPrototypeOf ( globalThis ) ,
263
+ ) ;
264
+ private globalProxy : typeof globalThis = new Proxy ( this . global , this ) ;
265
+ private isEnvSetup = false ;
266
+ private propertyToValue = new Map < string | symbol , unknown > ( ) ;
267
+ private leftovers : Array < { property : string | symbol ; value : unknown } > = [ ] ;
268
+
269
+ constructor ( ) {
270
+ this . register = this . register . bind ( this ) ;
271
+ }
272
+
273
+ proxy ( ) : typeof globalThis {
274
+ return this . globalProxy ;
275
+ }
276
+
277
+ /**
278
+ * Marks that the environment setup has completed, and properties set on
279
+ * the global object from now on should be shredded at teardown.
280
+ */
281
+ envSetupCompleted ( ) : void {
282
+ this . isEnvSetup = true ;
283
+ }
284
+
285
+ /**
286
+ * Shreds any property that was set on the global object, except for:
287
+ * 1. Properties that were set before {@link #envSetupCompleted} was invoked.
288
+ * 2. Properties protected by {@link #setNotShreddable}.
289
+ */
290
+ clear ( ) : void {
291
+ for ( const { property, value} of [
292
+ ...[ ...this . propertyToValue . entries ( ) ] . map ( ( [ property , value ] ) => ( {
293
+ property,
294
+ value,
295
+ } ) ) ,
296
+ ...this . leftovers ,
297
+ ] ) {
298
+ /*
299
+ * react-native invoke its custom `performance` property after env teardown.
300
+ * its setup file should use `setNotShreddable` to prevent this.
301
+ */
302
+ if ( property !== 'performance' ) {
303
+ shred ( value ) ;
304
+ }
305
+ }
306
+ this . propertyToValue . clear ( ) ;
307
+ this . leftovers = [ ] ;
308
+ this . global = { } as typeof globalThis ;
309
+ this . globalProxy = { } as typeof globalThis ;
310
+ }
311
+
312
+ defineProperty (
313
+ target : typeof globalThis ,
314
+ property : string | symbol ,
315
+ attributes : PropertyDescriptor ,
316
+ ) : boolean {
317
+ const newAttributes = { ...attributes } ;
318
+
319
+ if ( 'set' in newAttributes && newAttributes . set !== undefined ) {
320
+ const originalSet = newAttributes . set ;
321
+ const register = this . register ;
322
+ newAttributes . set = value => {
323
+ originalSet ( value ) ;
324
+ const newValue = Reflect . get ( target , property ) ;
325
+ register ( property , newValue ) ;
326
+ } ;
327
+ }
328
+
329
+ const result = Reflect . defineProperty ( target , property , newAttributes ) ;
330
+
331
+ if ( 'value' in newAttributes ) {
332
+ this . register ( property , newAttributes . value ) ;
333
+ }
334
+
335
+ return result ;
336
+ }
337
+
338
+ deleteProperty (
339
+ target : typeof globalThis ,
340
+ property : string | symbol ,
341
+ ) : boolean {
342
+ const result = Reflect . deleteProperty ( target , property ) ;
343
+ const value = this . propertyToValue . get ( property ) ;
344
+ if ( value ) {
345
+ this . leftovers . push ( { property, value} ) ;
346
+ this . propertyToValue . delete ( property ) ;
347
+ }
348
+ return result ;
349
+ }
350
+
351
+ private register ( property : string | symbol , value : unknown ) {
352
+ const currentValue = this . propertyToValue . get ( property ) ;
353
+ if ( value !== currentValue ) {
354
+ if ( ! this . isEnvSetup && isShreddable ( value ) ) {
355
+ setNotShreddable ( value ) ;
356
+ }
357
+ if ( currentValue ) {
358
+ this . leftovers . push ( { property, value : currentValue } ) ;
359
+ }
360
+
361
+ this . propertyToValue . set ( property , value ) ;
362
+ }
363
+ }
364
+ }
0 commit comments