Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
#15638 Fix destruction of core nodejs modules that was introduced in #…
  • Loading branch information
eyalroth committed Jun 2, 2025
commit 8ee9a7685a5decf1099021d00209d1af679a7f40
2 changes: 2 additions & 0 deletions .github/workflows/test-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jobs:
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2.0.0
- name: run node-env tests
run: yarn test-node-env
- name: run tests
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2
with:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ jobs:
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2.0.0
- name: run node-env tests
run: yarn test-node-env
- name: run tests
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3.0.2
with:
Expand Down
3 changes: 3 additions & 0 deletions examples/react-native/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ const {resolve} = require('path');

module.exports = {
preset: 'react-native',
testEnvironmentOptions: {
disableGlobalsCleanup: 'true',
},
// this is specific to the Jest repo, not generally needed (the files we ignore will be in node_modules which is ignored by default)
transformIgnorePatterns: [resolve(__dirname, '../../packages')],
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
"test-ts": "yarn jest --config jest.config.ts.mjs",
"test-types": "yarn tstyche",
"test-with-type-info": "yarn jest e2e/__tests__/jest.config.ts.test.ts",
"test-node-env": "yarn jest packages/jest-environment-node/src/__tests__",
"test": "yarn lint && yarn jest",
"typecheck": "yarn typecheck:examples && yarn typecheck:tests",
"typecheck:examples": "tsc -p examples/expect-extend && tsc -p examples/typescript",
Expand Down
3 changes: 2 additions & 1 deletion packages/jest-environment-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"jest-util": "workspace:*"
},
"devDependencies": {
"@jest/test-utils": "workspace:*"
"@jest/test-utils": "workspace:*",
"clsx": "^2.0.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@
*/

import type {EnvironmentContext} from '@jest/environment';
import {makeGlobalConfig, makeProjectConfig} from '@jest/test-utils';
import {
makeGlobalConfig,
makeProjectConfig,
onNodeVersions,
} from '@jest/test-utils';
import NodeEnvironment from '../';
import {AsyncLocalStorage, createHook} from 'async_hooks';
import {clsx} from 'clsx';

const context: EnvironmentContext = {
console,
Expand Down Expand Up @@ -91,4 +97,24 @@ describe('NodeEnvironment', () => {
test('dispatch event', () => {
new EventTarget().dispatchEvent(new Event('foo'));
});

test('set modules on global', () => {
(globalThis as any).async_hooks = require('async_hooks');
(globalThis as any).AsyncLocalStorage =
require('async_hooks').AsyncLocalStorage;
(globalThis as any).createHook = require('async_hooks').createHook;
(globalThis as any).clsx = require('clsx');
expect(AsyncLocalStorage).toBeDefined();
expect(clsx).toBeDefined();
expect(createHook).toBeDefined();
expect(createHook({})).toBeDefined();
expect(clsx()).toBeDefined();
});

onNodeVersions('>=19.8.0', () => {
test('use static function from core module set on global', () => {
expect(AsyncLocalStorage.snapshot).toBeDefined();
expect(AsyncLocalStorage.snapshot()).toBeDefined();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,32 @@
* LICENSE file in the root directory of this source tree.
*/

import {AsyncLocalStorage, createHook} from 'async_hooks';
import {clsx} from 'clsx';
import {onNodeVersions} from '@jest/test-utils';

describe('NodeEnvironment 2', () => {
test('dispatch event', () => {
new EventTarget().dispatchEvent(new Event('foo'));
});

test('set modules on global', () => {
(globalThis as any).async_hooks = require('async_hooks');
(globalThis as any).AsyncLocalStorage =
require('async_hooks').AsyncLocalStorage;
(globalThis as any).createHook = require('async_hooks').createHook;
(globalThis as any).clsx = require('clsx');
expect(AsyncLocalStorage).toBeDefined();
expect(clsx).toBeDefined();
expect(createHook).toBeDefined();
expect(createHook({})).toBeDefined();
expect(clsx()).toBeDefined();
});

onNodeVersions('>=19.8.0', () => {
test('use static function from core module set on global', () => {
expect(AsyncLocalStorage.snapshot).toBeDefined();
expect(AsyncLocalStorage.snapshot()).toBeDefined();
});
});
});
10 changes: 2 additions & 8 deletions packages/jest-environment-node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,20 +270,14 @@ class GlobalProxy implements ProxyHandler<typeof globalThis> {
* 2. Properties protected by {@link #protectProperties}.
*/
clear(): void {
for (const {property, value} of [
for (const {value} of [
...[...this.propertyToValue.entries()].map(([property, value]) => ({
property,
value,
})),
...this.leftovers,
]) {
/*
* React Native's test setup invokes their custom `performance` property after env teardown.
* Once they start using `protectProperties`, we can get rid of this.
*/
if (property !== 'performance') {
deleteProperties(value);
}
deleteProperties(value);
}
this.propertyToValue.clear();
this.leftovers = [];
Expand Down
5 changes: 4 additions & 1 deletion packages/jest-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
deepCyclicCopy,
invariant,
isNonNullable,
protectProperties,
} from 'jest-util';
import {
createOutsideJestVmPath,
Expand Down Expand Up @@ -1767,7 +1768,9 @@ export default class Runtime {
return this._getMockedNativeModule();
}

return require(moduleName);
const coreModule = require(moduleName);
protectProperties(coreModule);
return coreModule;
}

private _importCoreModule(moduleName: string, context: VMContext) {
Expand Down
62 changes: 50 additions & 12 deletions packages/jest-util/src/garbage-collection-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/

const PROTECT_PROPERTY = Symbol.for('$$jest-protect-from-deletion');
const PROTECT_SYMBOL = Symbol.for('$$jest-protect-from-deletion');

/**
* Deletes all the properties from the given value (if it's an object),
Expand All @@ -15,12 +15,13 @@ const PROTECT_PROPERTY = Symbol.for('$$jest-protect-from-deletion');
*/
export function deleteProperties(value: unknown): void {
if (canDeleteProperties(value)) {
const protectedProperties = Reflect.get(value, PROTECT_PROPERTY);
if (!Array.isArray(protectedProperties) || protectedProperties.length > 0) {
for (const key of Reflect.ownKeys(value)) {
if (!protectedProperties?.includes(key)) {
Reflect.deleteProperty(value, key);
}
const protectedKeys = getProtectedKeys(
value,
Reflect.get(value, PROTECT_SYMBOL),
);
for (const key of Reflect.ownKeys(value)) {
if (!protectedKeys.includes(key) && key !== PROTECT_SYMBOL) {
Reflect.deleteProperty(value, key);
}
}
}
Expand All @@ -31,15 +32,40 @@ export function deleteProperties(value: unknown): void {
*
* @param value The given value.
* @param properties If the array contains any property,
* then only these properties will not be deleted; otherwise if the array is empty,
* all properties will not be deleted.
* then only these properties will be protected; otherwise if the array is empty,
* all properties will be protected.
* @param depth Determines how "deep" the protection should be.
* A value of 0 means that only the top-most properties will be protected,
* while a value larger than 0 means that deeper levels of nesting will be protected as well.
*/
export function protectProperties<T extends object>(
export function protectProperties<T>(
value: T,
properties: Array<keyof T> = [],
depth = 2,
): boolean {
if (canDeleteProperties(value)) {
return Reflect.set(value, PROTECT_PROPERTY, properties);
if (
depth >= 0 &&
canDeleteProperties(value) &&
!Reflect.has(value, PROTECT_SYMBOL)
) {
const result = Reflect.set(value, PROTECT_SYMBOL, properties);
for (const key of getProtectedKeys(value, properties)) {
const originalEmitWarning = process.emitWarning;
try {
// Reflect.get may cause deprecation warnings, so we disable them temporarily
// eslint-disable-next-line @typescript-eslint/no-empty-function
process.emitWarning = () => {};

const nested = Reflect.get(value, key);
protectProperties(nested, [], depth - 1);
} catch {
// Reflect.get might fail in certain edge-cases
// Instead of failing the entire process, we will skip the property.
} finally {
process.emitWarning = originalEmitWarning;
}
}
return result;
}
return false;
}
Expand All @@ -57,3 +83,15 @@ export function canDeleteProperties(value: unknown): value is object {

return false;
}

function getProtectedKeys<T extends object>(
value: T,
properties: Array<keyof T> | undefined,
): Array<string | symbol | number> {
if (properties === undefined) {
return [];
}
const protectedKeys =
properties.length > 0 ? properties : Reflect.ownKeys(value);
return protectedKeys.filter(key => PROTECT_SYMBOL !== key);
}
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -13869,6 +13869,7 @@ __metadata:
"@jest/test-utils": "workspace:*"
"@jest/types": "workspace:*"
"@types/node": "*"
clsx: ^2.0.0
jest-mock: "workspace:*"
jest-util: "workspace:*"
languageName: unknown
Expand Down