@@ -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+ canDeleteProperties ,
19+ deleteProperties ,
20+ installCommonGlobals ,
21+ protectProperties ,
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
@@ -209,6 +217,7 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
209217 this . context = null ;
210218 this . fakeTimers = null ;
211219 this . fakeTimersModern = null ;
220+ this . _globalProxy . clear ( ) ;
212221 }
213222
214223 exportConditions ( ) : Array < string > {
@@ -221,3 +230,116 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
221230}
222231
223232export const TestEnvironment = NodeEnvironment ;
233+
234+ /**
235+ * Creates a new empty global object and wraps it with a {@link Proxy}.
236+ *
237+ * The purpose is to register any property set on the global object,
238+ * and {@link #deleteProperties} on them at environment teardown,
239+ * to clean up memory and prevent leaks.
240+ */
241+ class GlobalProxy implements ProxyHandler < typeof globalThis > {
242+ private global : typeof globalThis = Object . create (
243+ Object . getPrototypeOf ( globalThis ) ,
244+ ) ;
245+ private globalProxy : typeof globalThis = new Proxy ( this . global , this ) ;
246+ private isEnvSetup = false ;
247+ private propertyToValue = new Map < string | symbol , unknown > ( ) ;
248+ private leftovers : Array < { property : string | symbol ; value : unknown } > = [ ] ;
249+
250+ constructor ( ) {
251+ this . register = this . register . bind ( this ) ;
252+ }
253+
254+ proxy ( ) : typeof globalThis {
255+ return this . globalProxy ;
256+ }
257+
258+ /**
259+ * Marks that the environment setup has completed, and properties set on
260+ * the global object from now on should be deleted at teardown.
261+ */
262+ envSetupCompleted ( ) : void {
263+ this . isEnvSetup = true ;
264+ }
265+
266+ /**
267+ * Deletes any property that was set on the global object, except for:
268+ * 1. Properties that were set before {@link #envSetupCompleted} was invoked.
269+ * 2. Properties protected by {@link #protectProperties}.
270+ */
271+ clear ( ) : void {
272+ for ( const { property, value} of [
273+ ...[ ...this . propertyToValue . entries ( ) ] . map ( ( [ property , value ] ) => ( {
274+ property,
275+ value,
276+ } ) ) ,
277+ ...this . leftovers ,
278+ ] ) {
279+ /*
280+ * react-native invoke its custom `performance` property after env teardown.
281+ * its setup file should use `protectProperties` to prevent this.
282+ */
283+ if ( property !== 'performance' ) {
284+ deleteProperties ( value ) ;
285+ }
286+ }
287+ this . propertyToValue . clear ( ) ;
288+ this . leftovers = [ ] ;
289+ this . global = { } as typeof globalThis ;
290+ this . globalProxy = { } as typeof globalThis ;
291+ }
292+
293+ defineProperty (
294+ target : typeof globalThis ,
295+ property : string | symbol ,
296+ attributes : PropertyDescriptor ,
297+ ) : boolean {
298+ const newAttributes = { ...attributes } ;
299+
300+ if ( 'set' in newAttributes && newAttributes . set !== undefined ) {
301+ const originalSet = newAttributes . set ;
302+ const register = this . register ;
303+ newAttributes . set = value => {
304+ originalSet ( value ) ;
305+ const newValue = Reflect . get ( target , property ) ;
306+ register ( property , newValue ) ;
307+ } ;
308+ }
309+
310+ const result = Reflect . defineProperty ( target , property , newAttributes ) ;
311+
312+ if ( 'value' in newAttributes ) {
313+ this . register ( property , newAttributes . value ) ;
314+ }
315+
316+ return result ;
317+ }
318+
319+ deleteProperty (
320+ target : typeof globalThis ,
321+ property : string | symbol ,
322+ ) : boolean {
323+ const result = Reflect . deleteProperty ( target , property ) ;
324+ const value = this . propertyToValue . get ( property ) ;
325+ if ( value ) {
326+ this . leftovers . push ( { property, value} ) ;
327+ this . propertyToValue . delete ( property ) ;
328+ }
329+ return result ;
330+ }
331+
332+ private register ( property : string | symbol , value : unknown ) {
333+ const currentValue = this . propertyToValue . get ( property ) ;
334+ if ( value !== currentValue ) {
335+ if ( ! this . isEnvSetup && canDeleteProperties ( value ) ) {
336+ protectProperties ( value ) ;
337+ }
338+ if ( currentValue ) {
339+ this . leftovers . push ( { property, value : currentValue } ) ;
340+ }
341+
342+ this . propertyToValue . set ( property , value ) ;
343+ }
344+ }
345+ }
0 commit comments