@@ -14,7 +14,12 @@ import type {
1414import { LegacyFakeTimers , ModernFakeTimers } from '@jest/fake-timers' ;
1515import type { Global } from '@jest/types' ;
1616import { ModuleMocker } from 'jest-mock' ;
17- import { installCommonGlobals } from 'jest-util' ;
17+ import {
18+ installCommonGlobals ,
19+ isShreddable ,
20+ setNotShreddable ,
21+ shred ,
22+ } from 'jest-util' ;
1823
1924type Timer = {
2025 id : number ;
@@ -80,12 +85,13 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
8085 moduleMocker : ModuleMocker | null ;
8186 customExportConditions = [ 'node' , 'node-addons' ] ;
8287 private readonly _configuredExportConditions ?: Array < string > ;
88+ private _globalProxy : GlobalProxy ;
8389
8490 // while `context` is unused, it should always be passed
8591 constructor ( config : JestEnvironmentConfig , _context : EnvironmentContext ) {
8692 const { projectConfig} = config ;
87- this . context = createContext ( ) ;
88-
93+ this . _globalProxy = new GlobalProxy ( ) ;
94+ this . context = createContext ( this . _globalProxy . proxy ( ) ) ;
8995 const global = runInContext (
9096 'this' ,
9197 Object . assign ( this . context , projectConfig . testEnvironmentOptions ) ,
@@ -194,6 +200,8 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
194200 config : projectConfig ,
195201 global,
196202 } ) ;
203+
204+ this . _globalProxy . envSetupCompleted ( ) ;
197205 }
198206
199207 // eslint-disable-next-line @typescript-eslint/no-empty-function
@@ -206,9 +214,29 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
206214 if ( this . fakeTimersModern ) {
207215 this . fakeTimersModern . dispose ( ) ;
208216 }
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+ }
210237 this . fakeTimers = null ;
211238 this . fakeTimersModern = null ;
239+ this . _globalProxy . clear ( ) ;
212240 }
213241
214242 exportConditions ( ) : Array < string > {
@@ -221,3 +249,116 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
221249}
222250
223251export 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