diff --git a/configs/eslint-config-compass/package.json b/configs/eslint-config-compass/package.json index 6a00549938b..9b8c6d399ce 100644 --- a/configs/eslint-config-compass/package.json +++ b/configs/eslint-config-compass/package.json @@ -18,8 +18,8 @@ "@babel/eslint-parser": "^7.14.3", "@mongodb-js/eslint-config-devtools": "^0.9.9", "@mongodb-js/eslint-plugin-compass": "^1.2.17", - "@typescript-eslint/eslint-plugin": "^8.43.0", - "@typescript-eslint/parser": "^8.43.0", + "@typescript-eslint/eslint-plugin": "^8.46.2", + "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-chai-friendly": "^1.1.0", @@ -27,7 +27,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-mocha": "^8.0.0", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0" + "eslint-plugin-react-hooks": "^7.0.1" }, "scripts": { "prettier": "prettier-compass", diff --git a/configs/testing-library-compass/src/index.tsx b/configs/testing-library-compass/src/index.tsx index 1b66ca01177..ebf00ab87a8 100644 --- a/configs/testing-library-compass/src/index.tsx +++ b/configs/testing-library-compass/src/index.tsx @@ -340,8 +340,12 @@ function createWrapper( const StoreGetter: React.FunctionComponent = ({ children }) => { const store = useStore(); const actions = useConnectionActions(); + // We're breaking the rules of hooks on purpose here to expose the values + // outside of the render + /* eslint-disable react-hooks/immutability */ wrapperState.connectionsStore.getState = store.getState.bind(store); wrapperState.connectionsStore.actions = actions; + /* eslint-enable react-hooks/immutability */ return <>{children}; }; const logger = { @@ -618,6 +622,9 @@ function createPluginWrapper< ) { const ref: { current: PluginContext } = { current: {} as any }; function ComponentWithProvider({ children, ...props }: any) { + // We're breaking the rules of hooks on purpose here to expose the ref + // outside of the render + // eslint-disable-next-line react-hooks/immutability const plugin = (ref.current = Plugin.useActivate( initialPluginProps ?? ({} as any) )); diff --git a/package-lock.json b/package-lock.json index 4a236c3a9a9..0c0819a78c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,8 +41,8 @@ "@babel/eslint-parser": "^7.14.3", "@mongodb-js/eslint-config-devtools": "^0.9.9", "@mongodb-js/eslint-plugin-compass": "^1.2.17", - "@typescript-eslint/eslint-plugin": "^8.43.0", - "@typescript-eslint/parser": "^8.43.0", + "@typescript-eslint/eslint-plugin": "^8.46.2", + "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-chai-friendly": "^1.1.0", @@ -50,7 +50,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-mocha": "^8.0.0", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0" + "eslint-plugin-react-hooks": "^7.0.1" }, "bin": { "eslint-compass": "bin/eslint.js" @@ -17268,15 +17268,15 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", - "integrity": "sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", + "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.43.0", - "@typescript-eslint/type-utils": "8.43.0", - "@typescript-eslint/utils": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/type-utils": "8.46.2", + "@typescript-eslint/utils": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -17290,7 +17290,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.43.0", + "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -17305,14 +17305,14 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.43.0.tgz", - "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", - "dependencies": { - "@typescript-eslint/scope-manager": "8.43.0", - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/typescript-estree": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", + "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", + "dependencies": { + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4" }, "engines": { @@ -17328,12 +17328,12 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.43.0.tgz", - "integrity": "sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", + "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.43.0", - "@typescript-eslint/types": "^8.43.0", + "@typescript-eslint/tsconfig-utils": "^8.46.2", + "@typescript-eslint/types": "^8.46.2", "debug": "^4.3.4" }, "engines": { @@ -17348,12 +17348,12 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.43.0.tgz", - "integrity": "sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", + "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", "dependencies": { - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0" + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17364,9 +17364,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.43.0.tgz", - "integrity": "sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", + "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -17379,13 +17379,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.43.0.tgz", - "integrity": "sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", + "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", "dependencies": { - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/typescript-estree": "8.43.0", - "@typescript-eslint/utils": "8.43.0", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/utils": "8.46.2", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -17402,9 +17402,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.43.0.tgz", - "integrity": "sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", + "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -17414,14 +17414,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.43.0.tgz", - "integrity": "sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==", - "dependencies": { - "@typescript-eslint/project-service": "8.43.0", - "@typescript-eslint/tsconfig-utils": "8.43.0", - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", + "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", + "dependencies": { + "@typescript-eslint/project-service": "8.46.2", + "@typescript-eslint/tsconfig-utils": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -17463,14 +17463,14 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.43.0.tgz", - "integrity": "sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", + "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.43.0", - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/typescript-estree": "8.43.0" + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -17485,11 +17485,11 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.43.0.tgz", - "integrity": "sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", + "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", "dependencies": { - "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/types": "8.46.2", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -25902,16 +25902,42 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, + "node_modules/eslint-plugin-react-hooks/node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/eslint-plugin-react/node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -28688,6 +28714,19 @@ "node": ">=10.0.0" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/highlight.js": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.5.1.tgz", @@ -48308,6 +48347,7 @@ "version": "9.4.28", "license": "SSPL", "dependencies": { + "@mongodb-js/compass-components": "^1.56.0", "eventemitter3": "^4.0.0", "react": "^17.0.2", "react-redux": "^8.1.3", @@ -52329,6 +52369,7 @@ "license": "SSPL", "dependencies": { "@mongodb-js/compass-app-registry": "^9.4.28", + "@mongodb-js/compass-components": "^1.59.0", "@mongodb-js/compass-logging": "^1.7.24", "@mongodb-js/mdb-experiment-js": "1.9.0", "hadron-ipc": "^3.5.22", @@ -62579,6 +62620,7 @@ "@mongodb-js/compass-app-registry": { "version": "file:packages/compass-app-registry", "requires": { + "@mongodb-js/compass-components": "^1.56.0", "@mongodb-js/eslint-config-compass": "^1.4.12", "@mongodb-js/mocha-config-compass": "^1.7.2", "@mongodb-js/prettier-config-compass": "^1.2.9", @@ -65668,6 +65710,7 @@ "version": "file:packages/compass-telemetry", "requires": { "@mongodb-js/compass-app-registry": "^9.4.28", + "@mongodb-js/compass-components": "^1.59.0", "@mongodb-js/compass-logging": "^1.7.24", "@mongodb-js/eslint-config-compass": "^1.4.12", "@mongodb-js/mdb-experiment-js": "1.9.0", @@ -66926,8 +66969,8 @@ "@babel/eslint-parser": "^7.14.3", "@mongodb-js/eslint-config-devtools": "^0.9.9", "@mongodb-js/eslint-plugin-compass": "^1.2.17", - "@typescript-eslint/eslint-plugin": "^8.43.0", - "@typescript-eslint/parser": "^8.43.0", + "@typescript-eslint/eslint-plugin": "^8.46.2", + "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-chai-friendly": "^1.1.0", @@ -66935,7 +66978,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-mocha": "^8.0.0", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0" + "eslint-plugin-react-hooks": "^7.0.1" } }, "@mongodb-js/eslint-config-devtools": { @@ -66947,14 +66990,14 @@ "@babel/eslint-parser": "^7.22.7", "@babel/preset-env": "^7.22.7", "@babel/preset-react": "^7.22.5", - "@typescript-eslint/eslint-plugin": "^8.43.0", - "@typescript-eslint/parser": "^8.43.0", + "@typescript-eslint/eslint-plugin": "^8.46.2", + "@typescript-eslint/parser": "^8.46.2", "eslint-config-prettier": "^8.3.0", "eslint-plugin-filename-rules": "^1.2.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-mocha": "^8.0.0", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0" + "eslint-plugin-react-hooks": "^7.0.1" } }, "@mongodb-js/eslint-plugin-compass": { @@ -72888,15 +72931,15 @@ } }, "@typescript-eslint/eslint-plugin": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", - "integrity": "sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", + "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", "requires": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.43.0", - "@typescript-eslint/type-utils": "8.43.0", - "@typescript-eslint/utils": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/type-utils": "8.46.2", + "@typescript-eslint/utils": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -72911,68 +72954,68 @@ } }, "@typescript-eslint/parser": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.43.0.tgz", - "integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==", - "requires": { - "@typescript-eslint/scope-manager": "8.43.0", - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/typescript-estree": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", + "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", + "requires": { + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4" } }, "@typescript-eslint/project-service": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.43.0.tgz", - "integrity": "sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", + "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", "requires": { - "@typescript-eslint/tsconfig-utils": "^8.43.0", - "@typescript-eslint/types": "^8.43.0", + "@typescript-eslint/tsconfig-utils": "^8.46.2", + "@typescript-eslint/types": "^8.46.2", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.43.0.tgz", - "integrity": "sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", + "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", "requires": { - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0" + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2" } }, "@typescript-eslint/tsconfig-utils": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.43.0.tgz", - "integrity": "sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", + "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", "requires": {} }, "@typescript-eslint/type-utils": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.43.0.tgz", - "integrity": "sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", + "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", "requires": { - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/typescript-estree": "8.43.0", - "@typescript-eslint/utils": "8.43.0", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/utils": "8.46.2", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" } }, "@typescript-eslint/types": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.43.0.tgz", - "integrity": "sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==" + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", + "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==" }, "@typescript-eslint/typescript-estree": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.43.0.tgz", - "integrity": "sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==", - "requires": { - "@typescript-eslint/project-service": "8.43.0", - "@typescript-eslint/tsconfig-utils": "8.43.0", - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/visitor-keys": "8.43.0", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", + "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", + "requires": { + "@typescript-eslint/project-service": "8.46.2", + "@typescript-eslint/tsconfig-utils": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -73000,22 +73043,22 @@ } }, "@typescript-eslint/utils": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.43.0.tgz", - "integrity": "sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", + "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", "requires": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.43.0", - "@typescript-eslint/types": "8.43.0", - "@typescript-eslint/typescript-estree": "8.43.0" + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2" } }, "@typescript-eslint/visitor-keys": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.43.0.tgz", - "integrity": "sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", + "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", "requires": { - "@typescript-eslint/types": "8.43.0", + "@typescript-eslint/types": "8.46.2", "eslint-visitor-keys": "^4.2.1" }, "dependencies": { @@ -80007,10 +80050,29 @@ } }, "eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", - "requires": {} + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "requires": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "dependencies": { + "zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==" + }, + "zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "requires": {} + } + } }, "eslint-scope": { "version": "5.1.1", @@ -82686,6 +82748,19 @@ "resolved": "https://registry.npmjs.org/heap-js/-/heap-js-2.3.0.tgz", "integrity": "sha512-E5303mzwQ+4j/n2J0rDvEPBN7GKjhis10oHiYOgjxsmxYgqG++hz9NyLLOXttzH8as/DyiBHYpUrJTZWYaMo8Q==" }, + "hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==" + }, + "hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "requires": { + "hermes-estree": "0.25.1" + } + }, "highlight.js": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.5.1.tgz", diff --git a/package.json b/package.json index 62f965c4b00..f3d69bba6ff 100644 --- a/package.json +++ b/package.json @@ -104,12 +104,12 @@ "cheerio": "1.0.0-rc.10" }, "@mongodb-js/eslint-config-devtools": { - "@typescript-eslint/eslint-plugin": "^8.43.0", - "@typescript-eslint/parser": "^8.43.0", + "@typescript-eslint/eslint-plugin": "^8.46.2", + "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.1", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0" + "eslint-plugin-react-hooks": "^7.0.1" }, "@leafygreen-ui/emotion": "^4.0.9", "@leafygreen-ui/lib": "^15.3.0", diff --git a/packages/compass-aggregations/src/components/pipeline-builder-workspace/pipeline-as-text-workspace/pipeline-editor.tsx b/packages/compass-aggregations/src/components/pipeline-builder-workspace/pipeline-as-text-workspace/pipeline-editor.tsx index 3aeae86f342..00c7639812f 100644 --- a/packages/compass-aggregations/src/components/pipeline-builder-workspace/pipeline-as-text-workspace/pipeline-editor.tsx +++ b/packages/compass-aggregations/src/components/pipeline-builder-workspace/pipeline-as-text-workspace/pipeline-editor.tsx @@ -9,6 +9,7 @@ import { useDarkMode, cx, useRequiredURLSearchParams, + useCurrentValueRef, } from '@mongodb-js/compass-components'; import { createAggregationAutocompleter, @@ -83,8 +84,7 @@ export const PipelineEditor: React.FunctionComponent = ({ const track = useTelemetry(); const connectionInfoRef = useConnectionInfoRef(); const editorInitialValueRef = useRef(pipelineText); - const editorCurrentValueRef = useRef(pipelineText); - editorCurrentValueRef.current = pipelineText; + const editorCurrentValueRef = useCurrentValueRef(pipelineText); const { utmSource, utmMedium } = useRequiredURLSearchParams(); @@ -112,7 +112,7 @@ export const PipelineEditor: React.FunctionComponent = ({ ); editorInitialValueRef.current = editorCurrentValueRef.current; } - }, [num_stages, track, connectionInfoRef]); + }, [editorCurrentValueRef, track, num_stages, connectionInfoRef]); const annotations: Annotation[] = useMemo(() => { return syntaxErrors diff --git a/packages/compass-aggregations/src/components/stage-editor/stage-editor.tsx b/packages/compass-aggregations/src/components/stage-editor/stage-editor.tsx index f197af3adb5..e329b7d62b9 100644 --- a/packages/compass-aggregations/src/components/stage-editor/stage-editor.tsx +++ b/packages/compass-aggregations/src/components/stage-editor/stage-editor.tsx @@ -14,6 +14,7 @@ import { Banner, useDarkMode, useRequiredURLSearchParams, + useCurrentValueRef, } from '@mongodb-js/compass-components'; import { changeStageValue, @@ -99,8 +100,7 @@ export const StageEditor = ({ const connectionInfoRef = useConnectionInfoRef(); const darkMode = useDarkMode(); const editorInitialValueRef = useRef(stageValue); - const editorCurrentValueRef = useRef(stageValue); - editorCurrentValueRef.current = stageValue; + const editorCurrentValueRef = useCurrentValueRef(stageValue); const fields = useAutocompleteFields(namespace); @@ -150,6 +150,7 @@ export const StageEditor = ({ editorInitialValueRef.current = editorCurrentValueRef.current; } }, [ + editorCurrentValueRef, track, num_stages, index, diff --git a/packages/compass-aggregations/src/modules/pipeline-builder/pipeline-preview-manager.spec.ts b/packages/compass-aggregations/src/modules/pipeline-builder/pipeline-preview-manager.spec.ts index 450f1262b83..2d8b30ecee7 100644 --- a/packages/compass-aggregations/src/modules/pipeline-builder/pipeline-preview-manager.spec.ts +++ b/packages/compass-aggregations/src/modules/pipeline-builder/pipeline-preview-manager.spec.ts @@ -31,7 +31,7 @@ describe('PipelinePreviewManager', function () { const result = await Promise.allSettled([ previewManager.getPreviewForStage(0, 'test.test', []), - previewManager.cancelPreviewForStage(0), + Promise.resolve(previewManager.cancelPreviewForStage(0)), ]); expect(result[0]).to.have.property('status', 'rejected'); @@ -108,7 +108,7 @@ describe('PipelinePreviewManager', function () { previewManager.getPreviewForStage(2, 'test.test', []), previewManager.getPreviewForStage(3, 'test.test', []), previewManager.getPreviewForStage(4, 'test.test', []), - previewManager.clearQueue(1), + Promise.resolve(previewManager.clearQueue(1)), ]); // Only pipeline for stage 0 was executed diff --git a/packages/compass-aggregations/src/modules/search-indexes.ts b/packages/compass-aggregations/src/modules/search-indexes.ts index 3347ce41593..e47224664ff 100644 --- a/packages/compass-aggregations/src/modules/search-indexes.ts +++ b/packages/compass-aggregations/src/modules/search-indexes.ts @@ -3,7 +3,7 @@ import type { PipelineBuilderThunkAction } from '.'; import type { SearchIndex } from 'mongodb-data-service'; import { isAction } from '../utils/is-action'; -enum SearchIndexesStatuses { +export enum SearchIndexesStatuses { INITIAL = 'INITIAL', LOADING = 'LOADING', READY = 'READY', @@ -39,7 +39,7 @@ export type SearchIndexesAction = type State = { isSearchIndexesSupported: boolean; indexes: SearchIndex[]; - status: SearchIndexesStatus; + status: SearchIndexesStatuses; }; export const INITIAL_STATE: State = { diff --git a/packages/compass-app-registry/package.json b/packages/compass-app-registry/package.json index b8c38ffb667..3ded692dfcb 100644 --- a/packages/compass-app-registry/package.json +++ b/packages/compass-app-registry/package.json @@ -48,6 +48,7 @@ "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." }, "dependencies": { + "@mongodb-js/compass-components": "^1.56.0", "eventemitter3": "^4.0.0", "react": "^17.0.2", "react-redux": "^8.1.3", diff --git a/packages/compass-app-registry/src/react-context.tsx b/packages/compass-app-registry/src/react-context.tsx index d4ad160dc55..0a2386cb4db 100644 --- a/packages/compass-app-registry/src/react-context.tsx +++ b/packages/compass-app-registry/src/react-context.tsx @@ -1,10 +1,5 @@ -import React, { - createContext, - useEffect, - useRef, - useContext, - useState, -} from 'react'; +import React, { createContext, useEffect, useContext, useState } from 'react'; +import { useInitialValue } from '@mongodb-js/compass-components'; import { globalAppRegistry, AppRegistry } from './app-registry'; /** @@ -57,7 +52,7 @@ export function GlobalAppRegistryProvider({ value?: AppRegistry; children?: React.ReactNode; }) { - const appRegistry = useRef(value ?? globalAppRegistry).current; + const appRegistry = useInitialValue(value ?? globalAppRegistry); return ( {children} @@ -73,11 +68,11 @@ export function AppRegistryProvider({ children, ...props }: AppRegistryProviderProps) { - const initialPropsRef = useRef(props); + const initialProps = useInitialValue(props); const { localAppRegistry: initialLocalAppRegistry, deactivateOnUnmount = true, - } = initialPropsRef.current; + } = initialProps; const globalAppRegistry = useGlobalAppRegistry(); const isTopLevelProvider = useIsTopLevelProvider(); diff --git a/packages/compass-app-registry/src/register-plugin.tsx b/packages/compass-app-registry/src/register-plugin.tsx index 66b329948ba..40951ad8c61 100644 --- a/packages/compass-app-registry/src/register-plugin.tsx +++ b/packages/compass-app-registry/src/register-plugin.tsx @@ -92,7 +92,7 @@ function LegacyRefluxProvider({ }) { const storeRef = useRef(store); const [state, setState] = useState(() => { - return storeRef.current.state; + return store.state; }); React.useEffect(() => { diff --git a/packages/compass-assistant/src/compass-assistant-drawer.tsx b/packages/compass-assistant/src/compass-assistant-drawer.tsx index a895ec5e00a..77837efc04b 100644 --- a/packages/compass-assistant/src/compass-assistant-drawer.tsx +++ b/packages/compass-assistant/src/compass-assistant-drawer.tsx @@ -120,6 +120,10 @@ export const ClearChatButton: React.FunctionComponent<{ if (confirmed) { await stop(); clearError(); + // Instead of breaking React rules, we should probably expose the "clear" + // as an interface on the chat class. Otherwise it's kinda expected taht + // we "mutate" messages directly to update the state + // eslint-disable-next-line react-hooks/immutability chat.messages = chat.messages.filter( (message) => message.metadata?.isPermanent ); diff --git a/packages/compass-assistant/src/compass-assistant-provider.spec.tsx b/packages/compass-assistant/src/compass-assistant-provider.spec.tsx index c5368006fb6..ac4f8e935ab 100644 --- a/packages/compass-assistant/src/compass-assistant-provider.spec.tsx +++ b/packages/compass-assistant/src/compass-assistant-provider.spec.tsx @@ -88,6 +88,8 @@ const TestComponent: React.FunctionComponent<{ return ( + {/* Breaking this rule is fine while none of the tests try to re-render the content */} + {/* eslint-disable-next-line react-hooks/static-components */} + {/* Breaking this rule is fine while none of the tests try to re-render the content */} + {/* eslint-disable-next-line react-hooks/static-components */} ( - entryPointName: - | 'explain plan' - | 'performance insights' - | 'connection error', - builder: (props: T) => EntryPointMessage - ) { - return (props: T) => { - if (!assistantActionsContext.current.ensureOptInAndSend) { - return; - } - - const { prompt, metadata } = builder(props); - void assistantActionsContext.current.ensureOptInAndSend( - { - text: prompt, - metadata: { - ...metadata, - source: entryPointName, - }, - }, - {}, - () => { - openDrawer(ASSISTANT_DRAWER_ID); - - track('Assistant Entry Point Used', { - source: entryPointName, - }); - } - ); - }; - }).current; - const assistantActionsContext = useRef({ - interpretExplainPlan: createEntryPointHandler( - 'explain plan', - buildExplainPlanPrompt - ), - interpretConnectionError: createEntryPointHandler( - 'connection error', - buildConnectionErrorPrompt - ), - tellMoreAboutInsight: createEntryPointHandler( - 'performance insights', - buildProactiveInsightsPrompt - ), - ensureOptInAndSend: async ( + const ensureOptInAndSend = useInitialValue(() => { + return async function ( message: SendMessage, options: SendOptions, callback: () => void - ) => { + ) { try { await atlasAiService.ensureAiFeatureAccess(); } catch { @@ -246,12 +205,59 @@ export const AssistantProvider: React.FunctionComponent< } await chat.sendMessage(message, options); - }, + }; + }); + + const createEntryPointHandler = useInitialValue(() => { + return function ( + entryPointName: + | 'explain plan' + | 'performance insights' + | 'connection error', + builder: (props: T) => EntryPointMessage + ) { + return function (props: T) { + const { prompt, metadata } = builder(props); + void ensureOptInAndSend( + { + text: prompt, + metadata: { + ...metadata, + source: entryPointName, + }, + }, + {}, + () => { + openDrawer(ASSISTANT_DRAWER_ID); + + track('Assistant Entry Point Used', { + source: entryPointName, + }); + } + ); + }; + }; + }); + + const assistantActionsContext = useInitialValue({ + interpretExplainPlan: createEntryPointHandler( + 'explain plan', + buildExplainPlanPrompt + ), + interpretConnectionError: createEntryPointHandler( + 'connection error', + buildConnectionErrorPrompt + ), + tellMoreAboutInsight: createEntryPointHandler( + 'performance insights', + buildProactiveInsightsPrompt + ), + ensureOptInAndSend, }); return ( - + {children} diff --git a/packages/compass-collection/src/components/collection-tab-provider.tsx b/packages/compass-collection/src/components/collection-tab-provider.tsx index 531e5c9fbf9..e82934e4ce1 100644 --- a/packages/compass-collection/src/components/collection-tab-provider.tsx +++ b/packages/compass-collection/src/components/collection-tab-provider.tsx @@ -1,7 +1,8 @@ -import React, { useContext, useRef } from 'react'; +import React, { useContext } from 'react'; import type { CollectionTabPluginMetadata } from '../modules/collection-tab'; import type { CompassPluginComponent } from '@mongodb-js/compass-app-registry'; import type { CollectionSubtab } from '@mongodb-js/compass-workspaces'; +import { useInitialValue } from '@mongodb-js/compass-components'; export interface CollectionTabPlugin { name: CollectionSubtab; @@ -28,9 +29,9 @@ const CollectionTabComponentsContext = export const CollectionTabsProvider: React.FunctionComponent< Partial > = ({ children, ...props }) => { - const valueRef = useRef({ ...defaultComponents, ...props }); + const valueRef = useInitialValue({ ...defaultComponents, ...props }); return ( - + {children} ); diff --git a/packages/compass-collection/src/components/collection-tab.tsx b/packages/compass-collection/src/components/collection-tab.tsx index 59c99f893e0..11e61c70f6a 100644 --- a/packages/compass-collection/src/components/collection-tab.tsx +++ b/packages/compass-collection/src/components/collection-tab.tsx @@ -331,6 +331,8 @@ const CollectionTab = ({ }; return ( + // This component is not created in render, just accessed from context + // eslint-disable-next-line react-hooks/static-components ): React.ReactElement { const darkMode = useDarkMode(); const [localOpen, setLocalOpen] = useState(_open ?? defaultOpen); - const setOpenRef = useRef(_setOpen); - setOpenRef.current = _setOpen; + const setOpenRef = useCurrentValueRef(_setOpen); const onOpenChange = useCallback(() => { setLocalOpen((prevValue) => { const newValue = !prevValue; setOpenRef.current?.(newValue); return newValue; }); - }, []); + }, [setOpenRef]); const regionId = useId(); const labelId = useId(); const open = typeof _open !== 'undefined' ? _open : localOpen; diff --git a/packages/compass-components/src/components/content-with-fallback.spec.tsx b/packages/compass-components/src/components/content-with-fallback.spec.tsx deleted file mode 100644 index d8329bc8751..00000000000 --- a/packages/compass-components/src/components/content-with-fallback.spec.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from 'react'; -import { - render, - screen, - cleanup, - waitFor, -} from '@mongodb-js/testing-library-compass'; -import { expect } from 'chai'; -import { ContentWithFallback } from './content-with-fallback'; - -describe('ContentWithFallback', function () { - afterEach(cleanup); - - function TestContentWithFallback({ - isContentReady, - fallbackTimeout, - contentAfterFallbackTimeout, - }: { - isContentReady: boolean; - fallbackTimeout?: number; - contentAfterFallbackTimeout?: number; - }) { - return ( - - ready && ( -
- I am ready! -
- ) - } - fallback={(notReady) => - notReady &&
I am not ready yet!
- } - isContentReady={isContentReady} - fallbackTimeout={fallbackTimeout} - contentAfterFallbackTimeout={contentAfterFallbackTimeout} - >
- ); - } - - it('should render content immediately if content is ready on the first render', function () { - render( - - ); - - expect(screen.getByText('I am ready!')).to.exist; - }); - - it('should render only the context menu when content is not ready on the first render', function () { - const container = document.createElement('div'); - - render( - , - { container } - ); - - expect(container.children.length).to.equal(2); - const [contentContainer, contextMenuContainer] = Array.from( - container.children - ); - expect(contentContainer.children.length).to.equal(1); - expect(contextMenuContainer.getAttribute('data-testid')).to.equal( - 'context-menu-container' - ); - const copyPasteContextMenu = contentContainer.children[0]; - expect(copyPasteContextMenu.children.length).to.equal(0); - expect(copyPasteContextMenu.getAttribute('data-testid')).to.equal( - 'copy-paste-context-menu-container' - ); - }); - - it('should render fallback when the timeout passes', async function () { - render( - - ); - - await waitFor(() => screen.getByText('I am not ready yet!')); - }); - - it('should render content with animation if rendered after fallback', async function () { - const { rerender } = render( - - ); - - await waitFor(() => screen.getByText('I am not ready yet!')); - - rerender( - - ); - - await waitFor(() => screen.getByText('I am ready!')); - - expect(screen.getByText('I am ready!')).to.have.attribute( - 'data-animated', - 'true' - ); - }); -}); diff --git a/packages/compass-components/src/components/content-with-fallback.tsx b/packages/compass-components/src/components/content-with-fallback.tsx deleted file mode 100644 index cb71a4a754d..00000000000 --- a/packages/compass-components/src/components/content-with-fallback.tsx +++ /dev/null @@ -1,181 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { useEffect, useState } from 'react'; -import { css, cx, keyframes } from '@leafygreen-ui/emotion'; - -enum RenderStatus { - Nothing = 'Nothing', - Fallback = 'Fallback', - Content = 'Content', - // If content is appearing after fallback was displayed, we want to signal - // this so that content might want to animate the transition - ContentAnimated = 'ContentAnimated', -} -function isContent(status: RenderStatus): boolean { - return [RenderStatus.ContentAnimated, RenderStatus.Content].includes(status); -} -function useRenderWithFallback( - isContentReady: boolean, - { contentAfterFallbackTimeout = 200, fallbackTimeout = 30 } = {} -) { - const [renderStatus, setRenderStatus] = useState( - isContentReady ? RenderStatus.Content : RenderStatus.Nothing - ); - - useEffect(() => { - if (isContent(renderStatus)) { - return; - } - if (isContentReady && renderStatus === RenderStatus.Nothing) { - setRenderStatus(RenderStatus.Content); - return; - } - if (isContentReady && renderStatus === RenderStatus.Fallback) { - const timeout = setTimeout(() => { - setRenderStatus(RenderStatus.ContentAnimated); - }, contentAfterFallbackTimeout); - return () => { - clearTimeout(timeout); - }; - } - if (!isContentReady && renderStatus === RenderStatus.Nothing) { - const timeout = setTimeout(() => { - setRenderStatus(RenderStatus.Fallback); - }, fallbackTimeout); - return () => { - clearTimeout(timeout); - }; - } - }, [ - renderStatus, - isContentReady, - contentAfterFallbackTimeout, - fallbackTimeout, - ]); - - return renderStatus; -} -export const ContentWithFallback: React.FunctionComponent<{ - content( - shouldRender: boolean, - shouldAnimate: boolean - ): React.ReactElement | boolean | null; - fallback(shouldRender: boolean): React.ReactElement | boolean | null; - isContentReady: boolean; - contentAfterFallbackTimeout?: number; - fallbackTimeout?: number; -}> = ({ - content, - fallback, - isContentReady, - contentAfterFallbackTimeout, - fallbackTimeout, -}) => { - const renderStatus = useRenderWithFallback(isContentReady, { - contentAfterFallbackTimeout, - fallbackTimeout, - }); - - return ( - <> - {content( - isContent(renderStatus), - renderStatus === RenderStatus.ContentAnimated - )} - {fallback(renderStatus === RenderStatus.Fallback)} - - ); -}; - -const contentWithFallbackContainer = css({ - position: 'relative', -}); - -const fadeInAnimation = keyframes({ - from: { - opacity: 0, - }, - to: { - opacity: 1, - }, -}); - -const contentContainer = css({ - position: 'relative', -}); - -const contentContainerFadeIn = css({ - animation: `${fadeInAnimation} .16s ease-out`, -}); - -const fallbackContainer = css({ - position: 'absolute', - top: 0, - right: 0, - bottom: 0, - left: 0, - pointerEvents: 'none', - display: 'none', -}); - -const fallbackContainerVisible = css({ - display: 'block', -}); - -type FadeInPlaceholderProps = { - isContentReady: boolean; - content(): React.ReactElement | boolean | null; - fallback(): React.ReactElement | boolean | null; - contentContainerProps?: React.HTMLProps; - fallbackContainerProps?: React.HTMLProps; -}; - -export const FadeInPlaceholder: React.FunctionComponent< - FadeInPlaceholderProps & - Omit, keyof FadeInPlaceholderProps> -> = ({ - content, - fallback, - isContentReady, - contentContainerProps = {}, - fallbackContainerProps = {}, - className, - ...props -}) => { - return ( -
- { - return ( - shouldRender && ( -
- {content()} -
- ) - ); - }} - fallback={(shouldRender) => { - return ( -
- {fallback()} -
- ); - }} - >
-
- ); -}; diff --git a/packages/compass-components/src/components/context-menu.tsx b/packages/compass-components/src/components/context-menu.tsx index bbf2597a12e..5496d6e624f 100644 --- a/packages/compass-components/src/components/context-menu.tsx +++ b/packages/compass-components/src/components/context-menu.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef } from 'react'; +import React, { useLayoutEffect, useMemo, useRef } from 'react'; import { Menu, MenuItem, MenuSeparator } from './leafygreen'; import { css, cx } from '@leafygreen-ui/emotion'; import { spacing } from '@leafygreen-ui/tokens'; @@ -75,10 +75,12 @@ export function ContextMenu({ const { position, itemGroups } = menu; // TODO: Remove when https://jira.mongodb.org/browse/LG-5342 is resolved - if (anchorRef.current) { - anchorRef.current.style.left = `${position.x}px`; - anchorRef.current.style.top = `${position.y}px`; - } + useLayoutEffect(() => { + if (anchorRef.current) { + anchorRef.current.style.left = `${position.x}px`; + anchorRef.current.style.top = `${position.y}px`; + } + }, [position]); return (
diff --git a/packages/compass-components/src/components/document-list/document-edit-actions-footer.tsx b/packages/compass-components/src/components/document-list/document-edit-actions-footer.tsx index c35202bd641..bcbd86063c5 100644 --- a/packages/compass-components/src/components/document-list/document-edit-actions-footer.tsx +++ b/packages/compass-components/src/components/document-list/document-edit-actions-footer.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import type HadronDocument from 'hadron-document'; import { DocumentEvents, ElementEvents } from 'hadron-document'; import type { Element } from 'hadron-document'; @@ -108,18 +114,6 @@ function useHadronDocumentStatus( [] ); - useEffect(() => { - if (status !== 'Initial') { - return; - } - - if (editing) { - updateStatus('Editing'); - } else if (deleting) { - updateStatus('Deleting'); - } - }, [status, updateStatus, editing, deleting]); - useEffect(() => { const onUpdate = () => { updateStatus( @@ -214,7 +208,20 @@ function useHadronDocumentStatus( } }, [status, updateStatus]); - return { status, updateStatus, error }; + const derivedStatus = useMemo(() => { + if (status !== 'Initial') { + return status; + } + if (editing) { + return 'Editing'; + } + if (deleting) { + return 'Deleting'; + } + return status; + }, [status, editing, deleting]); + + return { status: derivedStatus, updateStatus, error }; } const container = css({ diff --git a/packages/compass-components/src/components/document-list/document.tsx b/packages/compass-components/src/components/document-list/document.tsx index 64788bf7c9e..f1ae20457f6 100644 --- a/packages/compass-components/src/components/document-list/document.tsx +++ b/packages/compass-components/src/components/document-list/document.tsx @@ -12,12 +12,11 @@ import { import { AutoFocusContext } from './auto-focus-context'; import { useForceUpdate } from './use-force-update'; import { calculateShowMoreToggleOffset, HadronElement } from './element'; -import { usePrevious } from './use-previous'; import VisibleFieldsToggle from './visible-field-toggle'; import { documentTypography } from './typography'; +import { useSyncStateOnPropChange } from '../../hooks/use-sync-state-on-prop-change'; function useHadronDocument(doc: HadronDocumentType) { - const prevDoc = usePrevious(doc); const forceUpdate = useForceUpdate(); const onVisibleElementsChanged = useCallback( @@ -44,12 +43,10 @@ function useHadronDocument(doc: HadronDocumentType) { [doc, forceUpdate] ); - useEffect(() => { - // Force update if the document that was passed to the component changed - if (prevDoc && prevDoc !== doc) { - forceUpdate(); - } - }, [prevDoc, doc, forceUpdate]); + // Force update if the document that was passed to the component changed + useSyncStateOnPropChange(() => { + forceUpdate(); + }, [doc]); useEffect(() => { doc.on(DocumentEvents.VisibleElementsChanged, onVisibleElementsChanged); @@ -105,12 +102,6 @@ const HadronDocument: React.FunctionComponent<{ type: 'key' | 'value' | 'type'; } | null>(null); - useEffect(() => { - if (!editing) { - setAutoFocus(null); - } - }, [editing]); - const handleVisibleFieldsChanged = useCallback( (totalVisibleFields: number) => { document.setMaxVisibleElementsCount(totalVisibleFields); @@ -138,7 +129,7 @@ const HadronDocument: React.FunctionComponent<{ data-testid="hadron-document" data-id={document.uuid} > - + {visibleElements.map((el, idx) => { return ( (null); - - if ( - !editor.current || - editor.current?.element !== el || - editor.current?.type !== el.currentType - ) { - const Editor = getEditorByType(el.currentType); - editor.current = new Editor(el); - } - - return editor.current; + return useMemo( + () => { + const Editor = getEditorByType(el.currentType); + return new Editor(el); + }, + // The list of deps is exhaustive, but we want `currentType` to be an + // explicit dependency of the memo to make sure that even if the `el` + // instance is the same, but `currentType` changed, we create a new editor + // instance + // eslint-disable-next-line react-hooks/exhaustive-deps + [el, el.currentType] + ); } function useHadronElement(el: HadronElementType) { const forceUpdate = useForceUpdate(); - const prevEl = usePrevious(el); const editor = useElementEditor(el); // NB: Duplicate key state is kept local to the component and not derived on // every change so that only the changed key is highlighed as duplicate @@ -105,11 +97,9 @@ function useHadronElement(el: HadronElementType) { [el, forceUpdate] ); - useEffect(() => { - if (prevEl && prevEl !== el) { - forceUpdate(); - } - }, [el, prevEl, forceUpdate]); + useSyncStateOnPropChange(() => { + forceUpdate(); + }, [el]); useEffect(() => { el.on(ElementEvents.Converted, onElementChanged); diff --git a/packages/compass-components/src/components/document-list/use-previous.tsx b/packages/compass-components/src/components/document-list/use-previous.tsx deleted file mode 100644 index 691f4af69b4..00000000000 --- a/packages/compass-components/src/components/document-list/use-previous.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { useEffect, useRef } from 'react'; - -export function usePrevious(value: T): T | undefined { - const ref = useRef(); - useEffect(() => { - ref.current = value; - }, [value]); - return ref.current; -} diff --git a/packages/compass-components/src/components/drawer-portal.spec.tsx b/packages/compass-components/src/components/drawer-portal.spec.tsx index d984c1af9cd..4e056ffcc71 100644 --- a/packages/compass-components/src/components/drawer-portal.spec.tsx +++ b/packages/compass-components/src/components/drawer-portal.spec.tsx @@ -21,6 +21,8 @@ describe('DrawerSection', function () { function TestDrawer() { const [count, _setCount] = useState(0); + // Exposed for testing purposes + // eslint-disable-next-line react-hooks/globals setCount = _setCount; return ( diff --git a/packages/compass-components/src/components/drawer-portal.tsx b/packages/compass-components/src/components/drawer-portal.tsx index 6e7c893d4e4..b32ba04466e 100644 --- a/packages/compass-components/src/components/drawer-portal.tsx +++ b/packages/compass-components/src/components/drawer-portal.tsx @@ -18,6 +18,7 @@ import { isEqual } from 'lodash'; import { rafraf } from '../utils/rafraf'; import { GuideCue, type GuideCueProps } from './guide-cue/guide-cue'; import { BaseFontSize, fontWeights } from '@leafygreen-ui/tokens'; +import { useInitialValue } from '../hooks/use-initial-value'; type ToolbarData = Required['toolbarData']; @@ -51,6 +52,9 @@ type DrawerActionsContextValue = { closeDrawer: () => void; updateToolbarData: (data: DrawerSectionProps) => void; removeToolbarData: (id: string) => void; + setCurrent: ( + fn: (current: DrawerActionsContextValue['current']) => void + ) => void; }; }; @@ -78,6 +82,7 @@ const DrawerActionsContext = React.createContext({ closeDrawer: () => undefined, updateToolbarData: () => undefined, removeToolbarData: () => undefined, + setCurrent: () => undefined, }, }); @@ -124,7 +129,7 @@ export const DrawerContentProvider: React.FunctionComponent<{ useState(false); const [drawerCurrentTab, setDrawerCurrentTab] = useState(null); - const drawerActions = useRef({ + const drawerActions = useRef({ openDrawer: () => undefined, closeDrawer: () => undefined, updateToolbarData: (data: DrawerSectionProps) => { @@ -147,6 +152,9 @@ export const DrawerContentProvider: React.FunctionComponent<{ }); }); }, + setCurrent: (fn) => { + fn(drawerActions.current); + }, }); const prevDrawerCurrentTabRef = React.useRef(null); @@ -187,11 +195,14 @@ export const DrawerContentProvider: React.FunctionComponent<{ const DrawerContextGrabber: React.FunctionComponent = ({ children }) => { const drawerToolbarContext = useDrawerToolbarContext(); - const actions = useContext(DrawerActionsContext); const openStateSetter = useContext(DrawerSetOpenStateContext); const currentTabSetter = useContext(DrawerSetCurrentTabContext); - actions.current.openDrawer = drawerToolbarContext.openDrawer; - actions.current.closeDrawer = drawerToolbarContext.closeDrawer; + const actions = useContext(DrawerActionsContext); + + actions.current.setCurrent((current) => { + current.openDrawer = drawerToolbarContext.openDrawer; + current.closeDrawer = drawerToolbarContext.closeDrawer; + }); useEffect(() => { openStateSetter(drawerToolbarContext.isDrawerOpen); @@ -453,7 +464,6 @@ export const DrawerSection: React.FunctionComponent = ({ 'Can not use DrawerSection without DrawerAnchor being mounted on the page' ); } - setPortalNode(querySectionPortal(drawerEl, props.id)); const mutationObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'childList') { @@ -498,7 +508,7 @@ export { DrawerDisplayMode }; export function useDrawerActions() { const actions = useContext(DrawerActionsContext); - const stableActions = useRef({ + const stableActions = useInitialValue({ openDrawer: (id: string) => { rafraf(() => { actions.current.openDrawer(id); @@ -508,7 +518,7 @@ export function useDrawerActions() { actions.current.closeDrawer(); }, }); - return stableActions.current; + return stableActions; } export const useDrawerState = () => { diff --git a/packages/compass-components/src/components/file-picker-dialog.tsx b/packages/compass-components/src/components/file-picker-dialog.tsx index efaaaafdb3b..f92d9fe5650 100644 --- a/packages/compass-components/src/components/file-picker-dialog.tsx +++ b/packages/compass-components/src/components/file-picker-dialog.tsx @@ -20,6 +20,7 @@ import { Link, Description, } from './leafygreen'; +import { useInitialValue } from '../hooks/use-initial-value'; const { base: redBaseColor } = palette.red; @@ -175,11 +176,11 @@ export const FileInputBackendContext = createContext< function useFileInputBackend() { const fileInputBackendContext = useContext(FileInputBackendContext); - const fileInputBackend = useRef( - fileInputBackendContext ? fileInputBackendContext() : null - ); + const fileInputBackend = useInitialValue(() => { + return fileInputBackendContext ? fileInputBackendContext() : null; + }); - return fileInputBackend.current; + return fileInputBackend; } // Matches require('electron') or require('@electron/remote') @@ -205,10 +206,12 @@ export type ElectronWebUtilsProvider = { export const FileInputBackendProvider: React.FunctionComponent<{ createFileInputBackend: (() => FileInputBackend) | null; }> = ({ children, createFileInputBackend }) => { - const createFileInputBackendRef = useRef(createFileInputBackend); + const initialCreateFileInputBackend = useInitialValue(() => { + return createFileInputBackend; + }); return ( - + {children} ); @@ -394,7 +397,7 @@ function FilePickerDialog({ }); onChange(files); }, - [onChange] + [backend, onChange] ); const handleOpenFileInput = useCallback(() => { diff --git a/packages/compass-components/src/components/file-selector.tsx b/packages/compass-components/src/components/file-selector.tsx index b113928fe89..aff6cf22eb7 100644 --- a/packages/compass-components/src/components/file-selector.tsx +++ b/packages/compass-components/src/components/file-selector.tsx @@ -1,4 +1,4 @@ -import React, { type InputHTMLAttributes, useRef } from 'react'; +import React, { type InputHTMLAttributes, useCallback, useRef } from 'react'; import { css } from '@leafygreen-ui/emotion'; const displayNoneStyles = css({ @@ -33,6 +33,10 @@ export function FileSelector({ [onSelect] ); + const onClick = useCallback(() => { + inputRef.current?.click(); + }, []); + return ( <> - {trigger({ - onClick: () => inputRef.current?.click(), - })} + {trigger( + // ref is not accessed in the render for rendering purposes, it's + // accessed in the callback, but the rule is not detecting it + // eslint-disable-next-line react-hooks/refs + { onClick } + )} ); } diff --git a/packages/compass-components/src/components/guide-cue/guide-cue.tsx b/packages/compass-components/src/components/guide-cue/guide-cue.tsx index d175f705398..a6250a5282a 100644 --- a/packages/compass-components/src/components/guide-cue/guide-cue.tsx +++ b/packages/compass-components/src/components/guide-cue/guide-cue.tsx @@ -10,7 +10,7 @@ import { guideCueService, type ShowCueEventDetail } from './guide-cue-service'; import { GuideCue as LGGuideCue } from '@leafygreen-ui/guide-cue'; import { GROUP_STEPS_MAP } from './guide-cue-groups'; import type { GroupName } from './guide-cue-groups'; -import { css, cx } from '../..'; +import { css, cx, useCurrentValueRef } from '../..'; import { rafraf } from '../../utils/rafraf'; const hiddenPopoverStyles = css({ @@ -43,8 +43,7 @@ export const GuideCueProvider: React.FC = ({ disabled = false, ...callbacks }) => { - const callbacksRef = useRef(callbacks); - callbacksRef.current = callbacks; + const callbacksRef = useCurrentValueRef(callbacks); const value = useMemo( () => ({ onNext(cue: Cue) { @@ -55,7 +54,7 @@ export const GuideCueProvider: React.FC = ({ }, disabled, }), - [disabled] + [disabled, callbacksRef] ); return ( @@ -285,7 +284,10 @@ export const GuideCue = ({ {description} )} - {trigger?.({ ref: refEl })} + {trigger?.( + // eslint-disable-next-line react-hooks/refs + { ref: refEl } + )} ); }; diff --git a/packages/compass-components/src/components/interactive-popover.tsx b/packages/compass-components/src/components/interactive-popover.tsx index c8bb22cda41..cbcc039bca3 100644 --- a/packages/compass-components/src/components/interactive-popover.tsx +++ b/packages/compass-components/src/components/interactive-popover.tsx @@ -163,6 +163,8 @@ function InteractivePopover({ const closeButtonId = 'close-button-id'; + // This is an allowed "custom ref" case + // eslint-disable-next-line react-hooks/refs return trigger({ onClick: onClickTrigger, ref: triggerRef, diff --git a/packages/compass-components/src/components/leafygreen.tsx b/packages/compass-components/src/components/leafygreen.tsx index 38ac4af0960..a12450e31f3 100644 --- a/packages/compass-components/src/components/leafygreen.tsx +++ b/packages/compass-components/src/components/leafygreen.tsx @@ -76,7 +76,7 @@ import { Tabs, Tab } from '@leafygreen-ui/tabs'; import TextArea from '@leafygreen-ui/text-area'; import LeafyGreenTextInput from '@leafygreen-ui/text-input'; import { SearchInput } from '@leafygreen-ui/search-input'; -export { usePrevious, useMergeRefs } from '@leafygreen-ui/hooks'; +export { useMergeRefs } from '@leafygreen-ui/hooks'; import Toggle from '@leafygreen-ui/toggle'; import LGTooltip from '@leafygreen-ui/tooltip'; import { diff --git a/packages/compass-components/src/components/signal-popover.tsx b/packages/compass-components/src/components/signal-popover.tsx index d357d052781..34eff45b629 100644 --- a/packages/compass-components/src/components/signal-popover.tsx +++ b/packages/compass-components/src/components/signal-popover.tsx @@ -17,6 +17,8 @@ import { spacing } from '@leafygreen-ui/tokens'; import { GuideCue } from './guide-cue/guide-cue'; import { useEffectOnChange } from '../hooks/use-effect-on-change'; import { rafraf } from '../utils/rafraf'; +import { useCurrentValueRef } from '../hooks/use-current-value-ref'; +import { useInitialValue } from '../hooks/use-initial-value'; type SignalTrackingHooks = { onSignalMount(id: string): void; @@ -51,8 +53,7 @@ const TrackingHooksContext = React.createContext({ const SignalHooksProvider: React.FunctionComponent< Partial > = ({ children, ..._hooks }) => { - const hooksRef = useRef(_hooks); - hooksRef.current = _hooks; + const hooksRef = useCurrentValueRef(_hooks); const hooks = useMemo(() => { return { onSignalMount(id: string) { @@ -70,9 +71,11 @@ const SignalHooksProvider: React.FunctionComponent< onSignalClose(id: string) { hooksRef.current.onSignalClose?.(id); }, - onAssistantButtonClick: hooksRef.current.onAssistantButtonClick, + onAssistantButtonClick() { + hooksRef.current.onAssistantButtonClick?.(); + }, }; - }, []); + }, [hooksRef]); return ( @@ -502,11 +505,11 @@ const SignalPopover: React.FunctionComponent = ({ // whole component lifecycle and at the same time avoid calling onSignalMount // for ids that were already mounted, we keep track of "mounted" signals in a // Set ref that will stay the same through the whole component lifecycle - const mountedSignalsRef = useRef(new Set()); + const mountedSignals = useInitialValue(new Set()); signals.forEach(({ id }) => { - if (!mountedSignalsRef.current.has(id)) { + if (!mountedSignals.has(id)) { hooks.onSignalMount(id); - mountedSignalsRef.current.add(id); + mountedSignals.add(id); } }); diff --git a/packages/compass-components/src/components/virtual-grid.tsx b/packages/compass-components/src/components/virtual-grid.tsx index a1201e08e59..3e28a968d67 100644 --- a/packages/compass-components/src/components/virtual-grid.tsx +++ b/packages/compass-components/src/components/virtual-grid.tsx @@ -280,6 +280,8 @@ export const VirtualGrid = forwardRef< }); const gridContainerProps = mergeProps( + // Ref is passed down and not used for "rendering" + // eslint-disable-next-line react-hooks/refs { ref, className: cx(container, classNames?.container) }, containerProps, rectProps diff --git a/packages/compass-components/src/components/workspace-container.tsx b/packages/compass-components/src/components/workspace-container.tsx index 5bd5650927a..765d2443222 100644 --- a/packages/compass-components/src/components/workspace-container.tsx +++ b/packages/compass-components/src/components/workspace-container.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useState } from 'react'; import { css, cx, keyframes } from '@leafygreen-ui/emotion'; import { palette } from '@leafygreen-ui/palette'; import { spacing } from '@leafygreen-ui/tokens'; @@ -125,10 +125,12 @@ function WorkspaceContainer({ }) { const darkMode = useDarkMode(); - const scrollContainer = useRef(null); + const [scrollContainer, setScrollContainer] = useState( + null + ); const [scrollDetectionTrigger, triggerStillInView] = useInView({ - root: scrollContainer.current, + root: scrollContainer, // Setting this value prevents flicker on initial mount. Default value is // `true`, if the value is not provided we assume that the trigger is at the // very top of the container, which is the most common case for the initial @@ -151,7 +153,7 @@ function WorkspaceContainer({ {typeof toolbar === 'function' ? toolbar() : toolbar}
)} -
+
{triggerStillInView || (
{typeof children === 'function' ? ( + // Ref is not used directly for "rendering" + // eslint-disable-next-line react-hooks/refs children(scrollDetectionTrigger) ) : ( <> diff --git a/packages/compass-components/src/hooks/use-confirmation.tsx b/packages/compass-components/src/hooks/use-confirmation.tsx index 71991c226c4..100e4153ec3 100644 --- a/packages/compass-components/src/hooks/use-confirmation.tsx +++ b/packages/compass-components/src/hooks/use-confirmation.tsx @@ -12,6 +12,7 @@ import type { ButtonProps } from '@leafygreen-ui/button'; import FormFieldContainer from '../components/form-field-container'; import { Banner, TextInput } from '../components/leafygreen'; import { spacing } from '@leafygreen-ui/tokens'; +import { useInitialValue } from './use-initial-value'; export { ConfirmationModalVariant }; @@ -118,39 +119,39 @@ const ConfirmationModalStateHandler: React.FunctionComponent = ({ confirmationId: -1, }); const callbackRef = useRef(); - const confirmationModalStateRef = useRef(); - - if (!confirmationModalStateRef.current) { - confirmationModalStateRef.current = confirmationModalState; - confirmationModalStateRef.current.onShowCallback = ({ - props, - resolve, - reject, - confirmationId, - }) => { - setConfirmationProps({ open: true, confirmationId, ...props }); - const onAbort = () => { - setConfirmationProps((state) => { - return { ...state, open: false }; - }); - reject(props.signal?.reason); - }; - callbackRef.current = (confirmed) => { - props.signal?.removeEventListener('abort', onAbort); - resolve(confirmed); + const _confirmationModalState = useInitialValue( + () => { + confirmationModalState.onShowCallback = ({ + props, + resolve, + reject, + confirmationId, + }) => { + setConfirmationProps({ open: true, confirmationId, ...props }); + const onAbort = () => { + setConfirmationProps((state) => { + return { ...state, open: false }; + }); + reject(props.signal?.reason); + }; + callbackRef.current = (confirmed) => { + props.signal?.removeEventListener('abort', onAbort); + resolve(confirmed); + }; + props.signal?.addEventListener('abort', onAbort); }; - props.signal?.addEventListener('abort', onAbort); - }; - } + return confirmationModalState; + } + ); useEffect(() => { return () => { callbackRef.current?.(false); - if (confirmationModalStateRef.current) { - confirmationModalStateRef.current.onShowCallback = null; + if (_confirmationModalState) { + _confirmationModalState.onShowCallback = null; } }; - }, []); + }, [_confirmationModalState]); const onUserAction = useCallback((value: boolean) => { setConfirmationProps((state) => { diff --git a/packages/compass-components/src/hooks/use-current-value-ref.tsx b/packages/compass-components/src/hooks/use-current-value-ref.tsx new file mode 100644 index 00000000000..7b743febd81 --- /dev/null +++ b/packages/compass-components/src/hooks/use-current-value-ref.tsx @@ -0,0 +1,22 @@ +import type React from 'react'; +import { useRef } from 'react'; + +/** + * Helper method to get a stable reference to the latest version of any value + * passed to render + * @param val value that will be stored in a ref and will continuously update to + * current every render + * @returns + */ +export function useCurrentValueRef(val: T): React.MutableRefObject { + const valRef = useRef(val); + // React doesn't recommend accessing refs in render, forcing ref access to be + // limited to effects. While this approach mostly works fine, there is a + // corner case where the returned value is accessed in a effect hook defined + // before the `useCurrentValueRef` call. While miniscule, to make sure it + // doesn't ever affects us, we are explicitly breaking out of the ref usage + // recommendations here + // eslint-disable-next-line react-hooks/refs + valRef.current = val; + return valRef; +} diff --git a/packages/compass-components/src/hooks/use-formatted-date.tsx b/packages/compass-components/src/hooks/use-formatted-date.tsx index 8ba19ad230b..c20de2edaa5 100644 --- a/packages/compass-components/src/hooks/use-formatted-date.tsx +++ b/packages/compass-components/src/hooks/use-formatted-date.tsx @@ -10,9 +10,6 @@ export function useFormattedDate(timestamp?: number): string | undefined { ); useEffect(() => { - setFormattedDate( - typeof timestamp === 'number' ? formatDate(timestamp) : undefined - ); const interval = setInterval(() => { setFormattedDate( typeof timestamp === 'number' ? formatDate(timestamp) : undefined diff --git a/packages/compass-components/src/hooks/use-initial-value.tsx b/packages/compass-components/src/hooks/use-initial-value.tsx new file mode 100644 index 00000000000..ce8b7a6b7bb --- /dev/null +++ b/packages/compass-components/src/hooks/use-initial-value.tsx @@ -0,0 +1,14 @@ +import { useState } from 'react'; + +/** + * Helper hook that takes any value and stores it during the initial render. + * After that the initial value is returned and is never changed again + + * @param val Any value that needs to be preserved unchanged after initial + * component render + * @returns + */ +export function useInitialValue(val: T | (() => T)): T { + const [initialVal] = useState(val); + return initialVal; +} diff --git a/packages/compass-components/src/hooks/use-persisted-state.tsx b/packages/compass-components/src/hooks/use-persisted-state.tsx index 30389226b61..705a2a3a763 100644 --- a/packages/compass-components/src/hooks/use-persisted-state.tsx +++ b/packages/compass-components/src/hooks/use-persisted-state.tsx @@ -17,7 +17,7 @@ function usePersistedState( const idRef = useRef(id); const storageRef = useRef(storage); const [state, setState] = useState(() => { - const initialStored = storageRef.current.getItem(idRef.current); + const initialStored = storage.getItem(id); if (initialStored) { try { return JSON.parse(initialStored); diff --git a/packages/compass-components/src/hooks/use-sync-state-on-prop-change.tsx b/packages/compass-components/src/hooks/use-sync-state-on-prop-change.tsx new file mode 100644 index 00000000000..2a62b54e032 --- /dev/null +++ b/packages/compass-components/src/hooks/use-sync-state-on-prop-change.tsx @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +/** + * Helper hook to run a setState sync logic in the render method when some + * component prop changes. This is recommended over calling `setState` directly + * inside effects, but _is still considered an antipattern_. Please refer to the + * React documentation for the guidance around how to avoid this pattern. + * + * @see {@link + * https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes} + * @deprecated React doesn't recommend this pattern, consider other approaches: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + */ +export function useSyncStateOnPropChange( + setStateFn: () => void, + deps: T[], + isEqual: (a: T, b: T) => boolean = (a, b) => { + return a === b; + } +) { + const [prevDeps, setPrevDeps] = useState(deps); + if ( + prevDeps.some((dep, index) => { + return !isEqual(dep, deps[index]); + }) + ) { + setStateFn(); + setPrevDeps(deps); + } +} diff --git a/packages/compass-components/src/hooks/use-toast.tsx b/packages/compass-components/src/hooks/use-toast.tsx index c650e59b224..594f5941c7c 100644 --- a/packages/compass-components/src/hooks/use-toast.tsx +++ b/packages/compass-components/src/hooks/use-toast.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useMemo, useRef } from 'react'; +import React, { useContext, useEffect, useMemo } from 'react'; import type { ToastProps } from '@leafygreen-ui/toast'; import { ToastProvider, @@ -6,6 +6,8 @@ import { } from '@leafygreen-ui/toast'; import { useStackedComponent } from './use-stacked-component'; import { css } from '..'; +import { useInitialValue } from './use-initial-value'; +import { useCurrentValueRef } from './use-current-value-ref'; export type ToastProperties = Pick< ToastProps, @@ -158,12 +160,9 @@ const ToastStateHandler: React.FunctionComponent = ({ children }) => { // interface in a ref so that we can safely use it inside our effects // // @see {@link https://jira.mongodb.org/browse/LG-3209} - const toastRef = useRef(useLeafygreenToast()); - const toastStateRef = useRef(); - - if (!toastStateRef.current) { - toastStateRef.current = toastState; - toastStateRef.current.onToastsChange = (action, toast) => { + const toastRef = useCurrentValueRef(useLeafygreenToast()); + const _toastState = useInitialValue(() => { + toastState.onToastsChange = (action, toast) => { if (action === 'push') { toastRef.current.pushToast({ ...(toast as ToastProperties), @@ -177,16 +176,16 @@ const ToastStateHandler: React.FunctionComponent = ({ children }) => { toastRef.current.popToast(toast.id); } }; - } - + return toastState; + }); useEffect(() => { return () => { - const ids = toastStateRef.current?.clear(); + const ids = _toastState.clear(); ids?.forEach((id) => { toastRef.current.popToast(id); }); }; - }, []); + }, [_toastState, toastRef]); return <>{children}; }; diff --git a/packages/compass-components/src/index.ts b/packages/compass-components/src/index.ts index 64b8bec7b76..93c3f3a5c42 100644 --- a/packages/compass-components/src/index.ts +++ b/packages/compass-components/src/index.ts @@ -154,10 +154,6 @@ export { ThemeProvider, } from './hooks/use-theme'; export { useThrottledProps } from './hooks/use-throttled-props'; -export { - ContentWithFallback, - FadeInPlaceholder, -} from './components/content-with-fallback'; export { InlineDefinition } from './components/inline-definition'; export type { GlyphName, LGGlyph } from '@leafygreen-ui/icon'; export { createGlyphComponent, createIconComponent } from '@leafygreen-ui/icon'; @@ -249,5 +245,8 @@ export type { NodeField, NodeGlyph, } from '@mongodb-js/diagramming'; +export { useInitialValue } from './hooks/use-initial-value'; +export { useCurrentValueRef } from './hooks/use-current-value-ref'; +export { useSyncStateOnPropChange } from './hooks/use-sync-state-on-prop-change'; // @experiment Skills in Atlas | Jira Epic: CLOUDP-346311 export { AtlasSkillsBanner } from './components/atlas-skills-banner'; diff --git a/packages/compass-connection-import-export/src/context.tsx b/packages/compass-connection-import-export/src/context.tsx index 21a95303cf3..df1d401fc19 100644 --- a/packages/compass-connection-import-export/src/context.tsx +++ b/packages/compass-connection-import-export/src/context.tsx @@ -1,6 +1,7 @@ -import React, { useCallback, useContext, useRef, useState } from 'react'; +import React, { useCallback, useContext, useState } from 'react'; import { ImportConnectionsModal } from './components/import-modal'; import { ExportConnectionsModal } from './components/export-modal'; +import { useInitialValue } from '@mongodb-js/compass-components'; type ConnectionImportExportService = { getHandlers(): { @@ -73,7 +74,7 @@ export const ConnectionImportExportProvider: React.FC = ({ children }) => { ); const connectionImportExportServiceRef = - useRef({ + useInitialValue({ getHandlers() { return { openConnectionImportModal, @@ -84,7 +85,7 @@ export const ConnectionImportExportProvider: React.FC = ({ children }) => { return ( {children} (fn: RenderItem): RenderItem { - const ref = useRef(fn); - ref.current = fn; + const ref = useCurrentValueRef(fn); return useMemo(() => { return (props) => ref.current(props); - }, []); + }, [ref]); } export function VirtualTree({ diff --git a/packages/compass-connections/src/connection-info-provider.tsx b/packages/compass-connections/src/connection-info-provider.tsx index 9783d6718ed..be4a8808844 100644 --- a/packages/compass-connections/src/connection-info-provider.tsx +++ b/packages/compass-connections/src/connection-info-provider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useRef } from 'react'; +import React, { createContext, useContext } from 'react'; import { type ConnectionInfo } from '@mongodb-js/connection-info'; import { createServiceLocator, @@ -13,6 +13,7 @@ import type { ConnectionId, ConnectionState, } from './stores/connections-store-redux'; +import { useCurrentValueRef } from '@mongodb-js/compass-components'; export type { ConnectionInfo }; @@ -74,19 +75,23 @@ export type ConnectionInfoRef = { export const useConnectionInfoRef = () => { const connectionId = useContext(ConnectionIdContext); const testEnvConnection = useContext(TestEnvCurrentConnectionContext); - const testEnvConnectionRef = useRef(testEnvConnection?.info); - testEnvConnectionRef.current = testEnvConnection?.info; + const testEnvConnectionRef = useCurrentValueRef(testEnvConnection?.info); const connectionInfoRefFromStore = useConnectionInfoRefForId( connectionId ?? '' ); const connectionInfoRef = connectionInfoRefFromStore.current ? connectionInfoRefFromStore : testEnvConnectionRef; + // We're accessing it for a runtime validation, not for rendering purposes + // eslint-disable-next-line react-hooks/refs if (!connectionInfoRef.current) { throw new Error( 'Can not access connection info inside a `useConnectionInfoRef` hook. Make sure that you are only calling this hook inside connected application scope' ); } + // This is basically a special-case useRef hook, so it's okay for us to access ref + // in render + // eslint-disable-next-line react-hooks/refs return connectionInfoRef as ConnectionInfoRef; }; diff --git a/packages/compass-connections/src/hooks/connection-tab-theme-provider.spec.tsx b/packages/compass-connections/src/hooks/connection-tab-theme-provider.spec.tsx index 8d6c0dae807..b02e87443e7 100644 --- a/packages/compass-connections/src/hooks/connection-tab-theme-provider.spec.tsx +++ b/packages/compass-connections/src/hooks/connection-tab-theme-provider.spec.tsx @@ -31,6 +31,8 @@ describe('ConnectionThemeProvider', function () { let capturedTheme: ReturnType = undefined; const TestComponent = () => { + // Doing this to test the value + // eslint-disable-next-line react-hooks/globals capturedTheme = useTabTheme(); return null; }; @@ -53,6 +55,8 @@ describe('ConnectionThemeProvider', function () { let capturedTheme: ReturnType = undefined; const TestComponent = () => { + // Doing this to test the value + // eslint-disable-next-line react-hooks/globals capturedTheme = useTabTheme(); return null; }; @@ -87,6 +91,8 @@ describe('ConnectionThemeProvider', function () { let capturedTheme: ReturnType = undefined; const TestComponent = () => { + // Doing this to test the value + // eslint-disable-next-line react-hooks/globals capturedTheme = useTabTheme(); return null; }; @@ -119,6 +125,8 @@ describe('ConnectionThemeProvider', function () { }; const TestComponent = () => { + // Doing this to test the value + // eslint-disable-next-line react-hooks/globals capturedTheme = useTabTheme(); return null; }; @@ -151,6 +159,8 @@ describe('ConnectionThemeProvider', function () { }; const TestComponent = () => { + // Doing this to test the value + // eslint-disable-next-line react-hooks/globals capturedTheme = useTabTheme(); return
Theme consumer
; }; diff --git a/packages/compass-connections/src/index.tsx b/packages/compass-connections/src/index.tsx index 3e821b9bc8a..772d2e76e19 100644 --- a/packages/compass-connections/src/index.tsx +++ b/packages/compass-connections/src/index.tsx @@ -1,7 +1,7 @@ import { preferencesLocator } from 'compass-preferences-model/provider'; import { registerCompassPlugin } from '@mongodb-js/compass-app-registry'; import type { connect as devtoolsConnect } from 'mongodb-data-service'; -import React, { useContext, useRef } from 'react'; +import React, { useContext } from 'react'; import { createLoggerLocator } from '@mongodb-js/compass-logging/provider'; import { connectionStorageLocator } from '@mongodb-js/connection-storage/provider'; import type { @@ -25,6 +25,7 @@ import { export type { ConnectionFeature } from './utils/connection-supports'; export { connectionSupports, connectable } from './utils/connection-supports'; import { compassAssistantServiceLocator } from '@mongodb-js/compass-assistant'; +import { useInitialValue } from '@mongodb-js/compass-components'; const ConnectionsComponent: React.FunctionComponent<{ /** @@ -148,9 +149,9 @@ const ConnectFnContext = React.createContext< export const ConnectFnProvider: React.FunctionComponent<{ connect?: typeof devtoolsConnect | undefined; }> = ({ connect, children }) => { - const ref = useRef(connect); + const connectFn = useInitialValue(() => connect); return ( - + {children} ); diff --git a/packages/compass-connections/src/stores/connections-store-redux.ts b/packages/compass-connections/src/stores/connections-store-redux.ts index c589594fd69..7b942ab01f8 100644 --- a/packages/compass-connections/src/stores/connections-store-redux.ts +++ b/packages/compass-connections/src/stores/connections-store-redux.ts @@ -2100,10 +2100,9 @@ const cleanupConnection = ( const connectionAttempt = ConnectionAttemptForConnection.get(connectionId); const dataService = DataServiceForConnection.get(connectionId); - void Promise.all([ - connectionAttempt?.cancelConnectionAttempt(), - dataService?.disconnect(), - ]).then( + connectionAttempt?.cancelConnectionAttempt(); + + void dataService?.disconnect().then( () => { debug('connection closed', connectionId); }, @@ -2219,7 +2218,10 @@ export const removeAllRecentConnections = (): ConnectionsThunkAction< toRemove.map((connection) => { dispatch(cleanupConnection(connection.info.id)); track('Connection Removed', {}, connection.info); - return connectionStorage.delete?.({ id: connection.info.id }); + return ( + connectionStorage.delete?.({ id: connection.info.id }) ?? + Promise.resolve() + ); }) ); diff --git a/packages/compass-connections/src/stores/store-context.tsx b/packages/compass-connections/src/stores/store-context.tsx index d770b9fd420..2b8d2e0e1e2 100644 --- a/packages/compass-connections/src/stores/store-context.tsx +++ b/packages/compass-connections/src/stores/store-context.tsx @@ -42,6 +42,7 @@ import { import { createServiceLocator } from '@mongodb-js/compass-app-registry'; import { isEqual } from 'lodash'; import type { ImportConnectionOptions } from '@mongodb-js/connection-storage/provider'; +import { useInitialValue } from '@mongodb-js/compass-components'; type ConnectionsStore = ReturnType extends Store< infer S, @@ -210,14 +211,14 @@ export function useConnectionsListRef(): { function useConnections() { const actions = useConnectionActions(); const connectionsListRef = useConnectionsListRef(); - return useRef({ + return useInitialValue({ ...actions, ...connectionsListRef, getDataServiceForConnection, on: connectionsEventEmitter.on, off: connectionsEventEmitter.off, removeListener: connectionsEventEmitter.removeListener, - }).current; + }); } export type ConnectionsService = ReturnType; diff --git a/packages/compass-context-menu/src/context-menu-provider.tsx b/packages/compass-context-menu/src/context-menu-provider.tsx index 659ab3073af..b23819ec3fd 100644 --- a/packages/compass-context-menu/src/context-menu-provider.tsx +++ b/packages/compass-context-menu/src/context-menu-provider.tsx @@ -55,6 +55,10 @@ export function ContextMenuProvider({ ); const onContextMenuOpenRef = useRef(onContextMenuOpen); + // NB: This is not using `useCurrentValueRef` from compass-components because + // this would cause a circular dep. We know what we're doing, see + // `useCurrentValueRef` documentation for explanation + // eslint-disable-next-line react-hooks/refs onContextMenuOpenRef.current = onContextMenuOpen; const handleClosingEvent = useCallback( diff --git a/packages/compass-crud/src/components/bulk-update-modal.tsx b/packages/compass-crud/src/components/bulk-update-modal.tsx index 9d9a0b7ef14..3b25d498b5f 100644 --- a/packages/compass-crud/src/components/bulk-update-modal.tsx +++ b/packages/compass-crud/src/components/bulk-update-modal.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect, useCallback } from 'react'; +import React, { useMemo, useState, useCallback } from 'react'; import type { UpdatePreview } from 'mongodb-data-service'; import type { Document } from 'bson'; import { toJSString } from 'mongodb-query-parser'; @@ -14,7 +14,6 @@ import { Description, Link, useDarkMode, - usePrevious, Modal, ModalFooter, Button, @@ -25,6 +24,7 @@ import { TextInput, useId, DocumentIcon, + useSyncStateOnPropChange, } from '@mongodb-js/compass-components'; import type { Annotation } from '@mongodb-js/compass-editor'; import { CodemirrorMultilineEditor } from '@mongodb-js/compass-editor'; @@ -352,7 +352,6 @@ export default function BulkUpdateModal({ const darkMode = useDarkMode(); const [text, setText] = useState(updateText); - const wasOpen = usePrevious(isOpen); const onChangeText = (value: string) => { setText(value); @@ -377,11 +376,11 @@ export default function BulkUpdateModal({ // This hack in addition to keeping the text state locally exists due to // reflux (unlike redux) being async. We can remove it once we move // compass-crud to redux. - useEffect(() => { - if (isOpen && !wasOpen) { + useSyncStateOnPropChange(() => { + if (isOpen) { setText(updateText); } - }, [isOpen, wasOpen, updateText]); + }, [isOpen]); const modalTitleAndButtonText = useMemo(() => { if (typeof count !== 'number') { diff --git a/packages/compass-crud/src/components/document-list.tsx b/packages/compass-crud/src/components/document-list.tsx index 6d847994e64..3ea092f0885 100644 --- a/packages/compass-crud/src/components/document-list.tsx +++ b/packages/compass-crud/src/components/document-list.tsx @@ -9,6 +9,7 @@ import { WorkspaceContainer, spacing, withDarkMode, + useCurrentValueRef, } from '@mongodb-js/compass-components'; import type { InsertDocumentDialogProps } from './insert-document-dialog'; import InsertDocumentDialog from './insert-document-dialog'; @@ -225,8 +226,9 @@ const useViewScrollTop = (view: DocumentView, isFetching: boolean) => { } }, [view, listViewScrollTop, jsonViewScrollTop]); - const currentViewInitialScrollTopRef = useRef(currentViewInitialScrollTop); - currentViewInitialScrollTopRef.current = currentViewInitialScrollTop; + const currentViewInitialScrollTopRef = useCurrentValueRef( + currentViewInitialScrollTop + ); const setCurrentViewInitialScrollTop = useCallback( (scrollTop: number) => { @@ -239,10 +241,9 @@ const useViewScrollTop = (view: DocumentView, isFetching: boolean) => { [view, setListViewScrollTop, setJsonViewScrollTop] ); - const setCurrentViewInitialScrollTopRef = useRef( + const setCurrentViewInitialScrollTopRef = useCurrentValueRef( setCurrentViewInitialScrollTop ); - setCurrentViewInitialScrollTopRef.current = setCurrentViewInitialScrollTop; // Preserve the scroll top for the current view when the entire component is // being unmounted @@ -259,7 +260,7 @@ const useViewScrollTop = (view: DocumentView, isFetching: boolean) => { scrollRef.current?.scrollTop ?? 0 ); }; - }, []); + }, [currentViewInitialScrollTopRef, setCurrentViewInitialScrollTopRef]); // Preserve the scroll top when documents are refreshed and loading List / // JSON view unmounts in between @@ -271,7 +272,7 @@ const useViewScrollTop = (view: DocumentView, isFetching: boolean) => { ) { scrollRef.current.scrollTop = currentViewInitialScrollTopRef.current; } - }, [isFetching]); + }, [currentViewInitialScrollTopRef, isFetching]); return { scrollRef, @@ -431,12 +432,17 @@ const DocumentList: React.FunctionComponent = (props) => { {} ); - const onColumnWidthChange = useCallback((newColumnWidths) => { - setColumnWidths({ - ...columnWidths, - ...newColumnWidths, - }); - }, []); + const onColumnWidthChange = useCallback( + (newColumnWidths: Record) => { + setColumnWidths((columnWidths) => { + return { + ...columnWidths, + ...newColumnWidths, + }; + }); + }, + [setColumnWidths] + ); const renderContent = useCallback( (scrollTriggerRef: React.Ref) => { @@ -520,8 +526,10 @@ const DocumentList: React.FunctionComponent = (props) => { props, outdated, query, - scrollRef, currentViewInitialScrollTop, + scrollRef, + columnWidths, + onColumnWidthChange, ] ); diff --git a/packages/compass-crud/src/components/json-editor.tsx b/packages/compass-crud/src/components/json-editor.tsx index 951bf08e024..3980c03f62c 100644 --- a/packages/compass-crud/src/components/json-editor.tsx +++ b/packages/compass-crud/src/components/json-editor.tsx @@ -11,6 +11,7 @@ import { DocumentList, palette, spacing, + useCurrentValueRef, useDarkMode, } from '@mongodb-js/compass-components'; import type { Document } from 'hadron-document'; @@ -80,10 +81,9 @@ const JSONEditor: React.FunctionComponent = ({ ); const [initialValue] = useState(() => doc.toEJSON()); const [containsErrors, setContainsErrors] = useState(false); - const setModifiedEJSONStringRef = useRef<(value: string | null) => void>( - doc.setModifiedEJSONString.bind(doc) - ); - setModifiedEJSONStringRef.current = doc.setModifiedEJSONString.bind(doc); + const setModifiedEJSONStringRef = useCurrentValueRef< + (value: string | null) => void + >(doc.setModifiedEJSONString.bind(doc)); useEffect(() => { const setModifiedEJSONString = setModifiedEJSONStringRef.current; @@ -94,7 +94,7 @@ const JSONEditor: React.FunctionComponent = ({ // value when the it's unmounted and is restored on next mount. setModifiedEJSONString(editing ? value : null); }; - }, [value, editing]); + }, [value, editing, setModifiedEJSONStringRef]); const handleCopy = useCallback(() => { copyToClipboard?.(doc); diff --git a/packages/compass-data-modeling/src/components/drawer/relationship-drawer-content.tsx b/packages/compass-data-modeling/src/components/drawer/relationship-drawer-content.tsx index b39f7b840b9..4a21b949cea 100644 --- a/packages/compass-data-modeling/src/components/drawer/relationship-drawer-content.tsx +++ b/packages/compass-data-modeling/src/components/drawer/relationship-drawer-content.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { connect } from 'react-redux'; import type { DataModelingState } from '../../store/reducer'; import { @@ -10,6 +10,7 @@ import { css, palette, TextArea, + useCurrentValueRef, } from '@mongodb-js/compass-components'; import { deleteRelationship, @@ -52,8 +53,7 @@ function useRelationshipFormFields( ): RelationshipFormFields & { onFieldChange: (key: keyof RelationshipFormFields, value: string) => void; } { - const onRelationshipChangeRef = useRef(onRelationshipChange); - onRelationshipChangeRef.current = onRelationshipChange; + const onRelationshipChangeRef = useCurrentValueRef(onRelationshipChange); const [local, foreign] = relationship.relationship; const localCollection = local.ns ?? ''; // Leafygreen select / combobox only supports string fields, so we stringify @@ -93,7 +93,7 @@ function useRelationshipFormFields( } onRelationshipChangeRef.current(newRelationship); }, - [relationship] + [onRelationshipChangeRef, relationship] ); return { localCollection, diff --git a/packages/compass-data-modeling/src/components/drawer/use-change-on-blur.tsx b/packages/compass-data-modeling/src/components/drawer/use-change-on-blur.tsx index d3278f2a802..64faa64689a 100644 --- a/packages/compass-data-modeling/src/components/drawer/use-change-on-blur.tsx +++ b/packages/compass-data-modeling/src/components/drawer/use-change-on-blur.tsx @@ -1,4 +1,5 @@ -import { useState, useLayoutEffect } from 'react'; +import { useSyncStateOnPropChange } from '@mongodb-js/compass-components'; +import { useState } from 'react'; export function useChangeOnBlur( value: string, @@ -10,9 +11,9 @@ export function useChangeOnBlur( onKeyDown: React.KeyboardEventHandler; } { const [_value, setValue] = useState(value); - useLayoutEffect(() => { - // Usually this is in sync with local value, but if it's changed externally, - // we run an effect and sync it back + // Usually this is in sync with local _value, but if it's changed externally, + // we run an effect and sync it back + useSyncStateOnPropChange(() => { setValue(value); }, [value]); return { diff --git a/packages/compass-e2e-tests/helpers/test-runner-global-fixtures.ts b/packages/compass-e2e-tests/helpers/test-runner-global-fixtures.ts index 8a6def6ee32..02c27028649 100644 --- a/packages/compass-e2e-tests/helpers/test-runner-global-fixtures.ts +++ b/packages/compass-e2e-tests/helpers/test-runner-global-fixtures.ts @@ -174,7 +174,10 @@ export async function mochaGlobalTeardown() { debug('Cleaning up after the tests ...'); await Promise.allSettled( cleanupFns.map((fn) => { - return fn(); + // We get a mix of sync and non-sync functions here. Awaiting even the + // sync ones just makes the logic simpler, but doesn't make + // typescript-eslint happy + return fn() as Promise; }) ); } diff --git a/packages/compass-export-to-language/src/components/modal.tsx b/packages/compass-export-to-language/src/components/modal.tsx index 2de9c80e047..677d9e489c4 100644 --- a/packages/compass-export-to-language/src/components/modal.tsx +++ b/packages/compass-export-to-language/src/components/modal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { connect } from 'react-redux'; import { InfoModal, @@ -158,32 +158,29 @@ const ExportToLanguageModal: React.FunctionComponent< const input = isQuery ? inputExpression.filter : inputExpression.aggregation; const [wasOpen, setWasOpen] = useState(false); - - useEffect(() => { - if (modalOpen && !wasOpen) { - const connectionInfo = connectionInfoRef.current; - - if (mode === 'Query') { - track('Query Export Opened', {}, connectionInfo); - } else if (mode === 'Delete Query') { - track('Delete Export Opened', {}, connectionInfo); - } else if (mode === 'Update Query') { - track('Update Export Opened', {}, connectionInfo); - } else if (mode === 'Pipeline') { - track( - 'Aggregation Export Opened', - { - ...stageCountForTelemetry(inputExpression), - }, - connectionInfo - ); - } - - track('Screen', { name: 'export_to_language_modal' }, connectionInfo); + if (modalOpen !== wasOpen && modalOpen) { + // Used for tracking, not for rendering + // eslint-disable-next-line react-hooks/refs + const connectionInfo = connectionInfoRef.current; + // All this tracking logic should probably be packed into an action, but + // this requires a bit more refactoring than just replacing the effect with + // a setState in render + if (mode === 'Query') { + track('Query Export Opened', {}, connectionInfo); + } else if (mode === 'Delete Query') { + track('Delete Export Opened', {}, connectionInfo); + } else if (mode === 'Update Query') { + track('Update Export Opened', {}, connectionInfo); + } else if (mode === 'Pipeline') { + track( + 'Aggregation Export Opened', + stageCountForTelemetry(inputExpression), + connectionInfo + ); } - + track('Screen', { name: 'export_to_language_modal' }, connectionInfo); setWasOpen(modalOpen); - }, [modalOpen, wasOpen, mode, inputExpression, track, connectionInfoRef]); + } const trackCopiedOutput = useCallback(() => { const commonProps = { diff --git a/packages/compass-generative-ai/src/components/generative-ai-input.tsx b/packages/compass-generative-ai/src/components/generative-ai-input.tsx index 2c8eb2e7e90..cef61af9072 100644 --- a/packages/compass-generative-ai/src/components/generative-ai-input.tsx +++ b/packages/compass-generative-ai/src/components/generative-ai-input.tsx @@ -20,7 +20,9 @@ import { keyframes, palette, spacing, + useCurrentValueRef, useDarkMode, + useSyncStateOnPropChange, } from '@mongodb-js/compass-components'; import { IntercomTrackingEvent, @@ -336,14 +338,15 @@ function GenerativeAIInput({ isAggregationGeneratedFromQuery = false, onResetIsAggregationGeneratedFromQuery, }: GenerativeAIInputProps) { - const promptTextInputRef = useRef(null); - const [showSuccess, setShowSuccess] = useState(false); - const [showEmptyResultsDisclaimer, setShowEmptyResultsDisclaimer] = - useState(false); const darkMode = useDarkMode(); const guideCueRef = useRef(null); + const promptTextInputRef = useRef(null); + const [showSuccess, setShowSuccess] = useState(false); - useEffect(() => { + const [showEmptyResultsDisclaimer, setShowEmptyResultsDisclaimer] = useState( + didGenerateEmptyResults + ); + useSyncStateOnPropChange(() => { if (didGenerateEmptyResults) { setShowEmptyResultsDisclaimer(true); } @@ -376,17 +379,20 @@ function GenerativeAIInput({ [aiPromptText, onClose, handleSubmit, isFetching, onCancelRequest] ); - useEffect(() => { + useSyncStateOnPropChange(() => { if (didSucceed) { setShowSuccess(true); + } + }, [didSucceed]); + useEffect(() => { + if (showSuccess) { const timeoutId = setTimeout(() => { setShowSuccess(false); }, 1500); - return () => clearTimeout(timeoutId); } - }, [didSucceed]); + }, [showSuccess]); useEffect(() => { if (show) { @@ -394,13 +400,12 @@ function GenerativeAIInput({ } }, [show]); - const onCancelRequestRef = useRef(onCancelRequest); - onCancelRequestRef.current = onCancelRequest; + const onCancelRequestRef = useCurrentValueRef(onCancelRequest); useEffect(() => { // When unmounting, ensure we cancel any ongoing requests. return () => onCancelRequestRef.current?.(); - }, []); + }, [onCancelRequestRef]); if (!show) { return null; diff --git a/packages/compass-generative-ai/src/store/atlas-optin-reducer.spec.ts b/packages/compass-generative-ai/src/store/atlas-optin-reducer.spec.ts index 7cebbb2fe9d..1ca19080744 100644 --- a/packages/compass-generative-ai/src/store/atlas-optin-reducer.spec.ts +++ b/packages/compass-generative-ai/src/store/atlas-optin-reducer.spec.ts @@ -176,7 +176,7 @@ describe('atlasOptInReducer', function () { await Promise.all([ store.dispatch(optIn()), - store.dispatch(cancelOptIn()), + Promise.resolve(store.dispatch(cancelOptIn())), ]); expect(store.getState().optIn).to.have.nested.property( 'state', diff --git a/packages/compass-global-writes/src/components/index.spec.tsx b/packages/compass-global-writes/src/components/index.spec.tsx index c5ab3f89791..4e5c6f94418 100644 --- a/packages/compass-global-writes/src/components/index.spec.tsx +++ b/packages/compass-global-writes/src/components/index.spec.tsx @@ -3,20 +3,27 @@ import { expect } from 'chai'; import { screen } from '@mongodb-js/testing-library-compass'; import { GlobalWrites } from './index'; import { renderWithStore } from './../../tests/create-store'; +import { ShardingStatuses } from '../store/reducer'; describe('Compass GlobalWrites Plugin', function () { it('renders plugin in NOT_READY state', async function () { - await renderWithStore(); + await renderWithStore( + + ); expect(screen.getByText(/loading/i)).to.exist; }); it('renders plugin in UNSHARDED state', async function () { - await renderWithStore(); + await renderWithStore( + + ); expect(screen.getByTestId('shard-collection-button')).to.exist; }); it('renders plugin in SHARDING state', async function () { - await renderWithStore(); + await renderWithStore( + + ); expect(screen.getByText(/sharding your collection/i)).to.exist; }); }); diff --git a/packages/compass-global-writes/src/components/index.tsx b/packages/compass-global-writes/src/components/index.tsx index 4a62e749417..73bb4a85cdc 100644 --- a/packages/compass-global-writes/src/components/index.tsx +++ b/packages/compass-global-writes/src/components/index.tsx @@ -7,7 +7,7 @@ import { SpinLoaderWithLabel, ConfirmationModalArea, } from '@mongodb-js/compass-components'; -import type { RootState, ShardingStatus } from '../store/reducer'; +import type { RootState } from '../store/reducer'; import { ShardingStatuses } from '../store/reducer'; import UnshardedState from './states/unsharded'; import ShardingState from './states/sharding'; @@ -36,13 +36,13 @@ const loaderStyles = css({ }); type GlobalWritesProps = { - shardingStatus: ShardingStatus; + shardingStatus: ShardingStatuses; }; function ShardingStateView({ shardingStatus, }: { - shardingStatus: Exclude; + shardingStatus: Exclude; }) { if (shardingStatus === ShardingStatuses.UNSHARDED) { return ; @@ -72,7 +72,7 @@ function ShardingStateView({ return ; } - if (shardingStatus === String(ShardingStatuses.LOADING_ERROR)) { + if (shardingStatus === ShardingStatuses.LOADING_ERROR) { return ; } diff --git a/packages/compass-global-writes/src/components/shard-zones-table.tsx b/packages/compass-global-writes/src/components/shard-zones-table.tsx index d7839355c14..5a296594494 100644 --- a/packages/compass-global-writes/src/components/shard-zones-table.tsx +++ b/packages/compass-global-writes/src/components/shard-zones-table.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Table, TableBody, @@ -17,6 +17,7 @@ import { type LGTableDataType, getExpandedRowModel, getFilteredRowModel, + useCurrentValueRef, } from '@mongodb-js/compass-components'; import type { ShardZoneData } from '../store/reducer'; import { ShardZonesDescription } from './shard-zones-description'; @@ -119,8 +120,7 @@ export function ShardZonesTable({ maxLeafRowFilterDepth: 2, }); - const tableRef = useRef(table); - tableRef.current = table; + const tableRef = useCurrentValueRef(table); const handleSearchTextChange = useCallback( (e: React.ChangeEvent) => { diff --git a/packages/compass-indexes/src/components/indexes/indexes.spec.tsx b/packages/compass-indexes/src/components/indexes/indexes.spec.tsx index 050c346e09a..1310fdd4851 100644 --- a/packages/compass-indexes/src/components/indexes/indexes.spec.tsx +++ b/packages/compass-indexes/src/components/indexes/indexes.spec.tsx @@ -23,6 +23,7 @@ import type { RootState } from '../../modules'; import type { Document } from 'mongodb'; import { CompassExperimentationProvider } from '@mongodb-js/compass-telemetry'; import { ExperimentTestGroup } from '@mongodb-js/compass-telemetry/provider'; +import { FetchStatuses } from '../../utils/fetch-status'; const renderIndexes = async ( options: Partial = {}, @@ -82,7 +83,7 @@ describe('Indexes Component', function () { regularIndexes: { indexes: [], error: 'Some random error', - status: 'ERROR', + status: FetchStatuses.ERROR, inProgressIndexes: [], }, }); @@ -124,7 +125,7 @@ describe('Indexes Component', function () { await renderIndexes(undefined, undefined, { indexView: 'regular-indexes', regularIndexes: { - status: 'NOT_READY', + status: FetchStatuses.NOT_READY, inProgressIndexes: [], indexes: [], }, @@ -161,7 +162,7 @@ describe('Indexes Component', function () { }, ] as RegularIndex[], error: undefined, - status: 'READY', + status: FetchStatuses.READY, inProgressIndexes: [], }, }); @@ -211,7 +212,7 @@ describe('Indexes Component', function () { }, ], error: undefined, - status: 'READY', + status: FetchStatuses.READY, }, }); @@ -269,7 +270,7 @@ describe('Indexes Component', function () { }, ], error: undefined, - status: 'READY', + status: FetchStatuses.READY, }, }); diff --git a/packages/compass-indexes/src/components/indexes/indexes.tsx b/packages/compass-indexes/src/components/indexes/indexes.tsx index 987d9859003..68adce4be02 100644 --- a/packages/compass-indexes/src/components/indexes/indexes.tsx +++ b/packages/compass-indexes/src/components/indexes/indexes.tsx @@ -25,7 +25,6 @@ import { VIEW_PIPELINE_UTILS } from '@mongodb-js/mongodb-constants'; import type { State as RegularIndexesState } from '../../modules/regular-indexes'; import type { State as SearchIndexesState } from '../../modules/search-indexes'; import { FetchStatuses } from '../../utils/fetch-status'; -import type { FetchStatus } from '../../utils/fetch-status'; import type { RootState } from '../../modules'; import { CreateSearchIndexModal, @@ -174,7 +173,7 @@ type IndexesProps = { collectionStats: CollectionStats; }; -function isRefreshingStatus(status: FetchStatus) { +function isRefreshingStatus(status: FetchStatuses) { return ( status === FetchStatuses.FETCHING || status === FetchStatuses.REFRESHING ); diff --git a/packages/compass-indexes/src/components/search-indexes-modals/base-search-index-modal.tsx b/packages/compass-indexes/src/components/search-indexes-modals/base-search-index-modal.tsx index 5726e0423f7..4cd71de8ad1 100644 --- a/packages/compass-indexes/src/components/search-indexes-modals/base-search-index-modal.tsx +++ b/packages/compass-indexes/src/components/search-indexes-modals/base-search-index-modal.tsx @@ -1,10 +1,4 @@ -import React, { - useCallback, - useEffect, - useRef, - useState, - useMemo, -} from 'react'; +import React, { useCallback, useRef, useState, useMemo } from 'react'; import { Modal, ModalFooter, @@ -222,14 +216,16 @@ export const BaseSearchIndexModal: React.FunctionComponent< undefined ); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(isModalOpen); + if (prevOpen !== isModalOpen) { if (isModalOpen) { setSearchIndexType(initialSearchIndexType); setIndexName(initialIndexName); setIndexDefinition(initialIndexDefinition); setParsingError(undefined); } - }, [isModalOpen, initialIndexName, initialIndexDefinition]); + setPrevOpen(isModalOpen); + } const onSearchIndexDefinitionChanged = useCallback( (newDefinition: string) => { diff --git a/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.spec.tsx b/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.spec.tsx index d5954e7b8c5..f8e70aff1bb 100644 --- a/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.spec.tsx +++ b/packages/compass-indexes/src/components/search-indexes-table/search-indexes-table.spec.tsx @@ -26,7 +26,7 @@ const renderIndexList = ( void; onEditIndexClick: (name: string) => void; onOpenCreateModalClick: () => void; @@ -56,7 +55,7 @@ type SearchIndexesTableProps = { onSearchIndexesClosed: (tabId: string) => void; }; -function isReadyStatus(status: FetchStatus) { +function isReadyStatus(status: FetchStatuses) { return ( status === FetchStatuses.READY || status === FetchStatuses.REFRESHING || diff --git a/packages/compass-indexes/src/modules/regular-indexes.ts b/packages/compass-indexes/src/modules/regular-indexes.ts index 661ea04d5c4..a989125da3f 100644 --- a/packages/compass-indexes/src/modules/regular-indexes.ts +++ b/packages/compass-indexes/src/modules/regular-indexes.ts @@ -7,9 +7,7 @@ import { } from '@mongodb-js/compass-components'; import { FetchStatuses, NOT_FETCHABLE_STATUSES } from '../utils/fetch-status'; -import type { FetchStatus } from '../utils/fetch-status'; import { FetchReasons } from '../utils/fetch-reason'; -import type { FetchReason } from '../utils/fetch-reason'; import { isAction } from '../utils/is-action'; import type { CreateIndexSpec } from './create-index'; import type { IndexesThunkAction, RootState } from '.'; @@ -130,7 +128,7 @@ type IndexesClosedAction = { type FetchIndexesStartedAction = { type: ActionTypes.FetchIndexesStarted; - reason: FetchReason; + reason: FetchReasons; }; type FetchIndexesSucceededAction = { @@ -171,7 +169,7 @@ type RollingIndexTimeoutCheckAction = { }; export type State = { - status: FetchStatus; + status: FetchStatuses; indexes: RegularIndex[]; inProgressIndexes: InProgressIndex[]; rollingIndexes?: RollingIndex[]; @@ -346,7 +344,7 @@ export default function reducer( } const fetchIndexesStarted = ( - reason: FetchReason + reason: FetchReasons ): FetchIndexesStartedAction => ({ type: ActionTypes.FetchIndexesStarted, reason, @@ -380,7 +378,7 @@ function pickCollectionStatFields(state: RootState) { } const fetchIndexes = ( - reason: FetchReason + reason: FetchReasons ): IndexesThunkAction, FetchIndexesActions> => { return async ( dispatch, @@ -425,8 +423,8 @@ const fetchIndexes = ( dataService.indexes(namespace), shouldFetchRollingIndexes ? rollingIndexesService.listRollingIndexes(namespace) - : undefined, - ] as [Promise, Promise | undefined]; + : Promise.resolve(undefined), + ] as [Promise, Promise]; const [indexes, rollingIndexes] = await Promise.all(promises); const indexesBefore = pickCollectionStatFields(getState()); dispatch(fetchIndexesSucceeded(indexes, rollingIndexes)); diff --git a/packages/compass-indexes/src/modules/search-indexes.ts b/packages/compass-indexes/src/modules/search-indexes.ts index 0261ef62711..0ba16f42773 100644 --- a/packages/compass-indexes/src/modules/search-indexes.ts +++ b/packages/compass-indexes/src/modules/search-indexes.ts @@ -9,10 +9,8 @@ import type { SearchIndex } from 'mongodb-data-service'; import { isAction } from '../utils/is-action'; import { FetchStatuses, NOT_FETCHABLE_STATUSES } from '../utils/fetch-status'; -import type { FetchStatus } from '../utils/fetch-status'; import { FetchReasons } from '../utils/fetch-reason'; -import type { FetchReason } from '../utils/fetch-reason'; import type { IndexesThunkAction } from '.'; import { switchToSearchIndexes } from './index-view'; @@ -48,7 +46,7 @@ export enum ActionTypes { type FetchSearchIndexesStartedAction = { type: ActionTypes.FetchSearchIndexesStarted; - reason: FetchReason; + reason: FetchReasons; }; type FetchSearchIndexesSucceededAction = { @@ -118,7 +116,7 @@ type UpdateSearchIndexState = { }; export type State = { - status: FetchStatus; + status: FetchStatuses; createIndex: CreateSearchIndexState; updateIndex: UpdateSearchIndexState; error?: string; @@ -376,7 +374,7 @@ export const updateSearchIndexClosed = (): UpdateSearchIndexClosedAction => ({ }); const fetchSearchIndexesStarted = ( - reason: FetchReason + reason: FetchReasons ): FetchSearchIndexesStartedAction => ({ type: ActionTypes.FetchSearchIndexesStarted, reason, @@ -596,7 +594,7 @@ type FetchSearchIndexesActions = | FetchSearchIndexesFailedAction; const fetchIndexes = ( - reason: FetchReason + reason: FetchReasons ): IndexesThunkAction, FetchSearchIndexesActions> => { return async (dispatch, getState, { dataService }) => { const { diff --git a/packages/compass-query-bar/src/index.tsx b/packages/compass-query-bar/src/index.tsx index 4e171702c27..76fde40d9bf 100644 --- a/packages/compass-query-bar/src/index.tsx +++ b/packages/compass-query-bar/src/index.tsx @@ -67,6 +67,8 @@ export const QueryBar: React.FunctionComponent< React.ComponentProps > = (props) => { const Component = useQueryBarComponent(); + // Component is not created, just accessed via context + // eslint-disable-next-line react-hooks/static-components return ; }; diff --git a/packages/compass-saved-aggregations-queries/src/components/edit-item-modal.tsx b/packages/compass-saved-aggregations-queries/src/components/edit-item-modal.tsx index e208a09596c..71adc681277 100644 --- a/packages/compass-saved-aggregations-queries/src/components/edit-item-modal.tsx +++ b/packages/compass-saved-aggregations-queries/src/components/edit-item-modal.tsx @@ -1,5 +1,9 @@ -import React, { useState, useEffect } from 'react'; -import { FormModal, TextInput } from '@mongodb-js/compass-components'; +import React, { useState } from 'react'; +import { + FormModal, + TextInput, + useSyncStateOnPropChange, +} from '@mongodb-js/compass-components'; import { connect } from 'react-redux'; import type { MapDispatchToProps, MapStateToProps } from 'react-redux'; import type { RootState } from '../stores'; @@ -21,7 +25,7 @@ const EditItemModal: React.FunctionComponent = ({ onCancel, }) => { const [name, setName] = useState(item?.name ?? ''); - useEffect(() => { + useSyncStateOnPropChange(() => { setName(item?.name ?? ''); }, [item]); diff --git a/packages/compass-saved-aggregations-queries/src/stores/aggregations-queries-items.ts b/packages/compass-saved-aggregations-queries/src/stores/aggregations-queries-items.ts index de4532f51fb..ec55078b044 100644 --- a/packages/compass-saved-aggregations-queries/src/stores/aggregations-queries-items.ts +++ b/packages/compass-saved-aggregations-queries/src/stores/aggregations-queries-items.ts @@ -75,8 +75,12 @@ export const fetchItems = (): SavedQueryAggregationThunkAction< { pipelineStorage, queryStorage } ): Promise => { const payload = await Promise.allSettled([ - (await pipelineStorage?.loadAll())?.map(mapAggregationToItem) ?? [], - (await queryStorage?.loadAll())?.map(mapQueryToItem) ?? [], + pipelineStorage?.loadAll().then((items) => { + return items.map(mapAggregationToItem); + }) ?? Promise.resolve([]), + queryStorage?.loadAll().then((items) => { + return items.map(mapQueryToItem); + }) ?? Promise.resolve([]), ]); dispatch({ type: ActionTypes.ITEMS_FETCHED, diff --git a/packages/compass-schema-validation/src/components/validation-editor.tsx b/packages/compass-schema-validation/src/components/validation-editor.tsx index 4b07677dd83..e4cc2660c9a 100644 --- a/packages/compass-schema-validation/src/components/validation-editor.tsx +++ b/packages/compass-schema-validation/src/components/validation-editor.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { debounce } from 'lodash'; import { connect } from 'react-redux'; import { useTelemetry } from '@mongodb-js/compass-telemetry/provider'; @@ -17,6 +17,7 @@ import { ButtonVariant, SpinLoader, Tooltip, + useCurrentValueRef, } from '@mongodb-js/compass-components'; import { CodemirrorMultilineEditor, @@ -191,18 +192,10 @@ export const ValidationEditor: React.FunctionComponent< const enableExportSchema = usePreference('enableExportSchema'); const track = useTelemetry(); const connectionInfoRef = useConnectionInfoRef(); - - const clearSampleDocumentsRef = useRef(clearSampleDocuments); - clearSampleDocumentsRef.current = clearSampleDocuments; - - const validatorChangedRef = useRef(validatorChanged); - validatorChangedRef.current = validatorChanged; - - const saveValidationRef = useRef(saveValidation); - saveValidationRef.current = saveValidation; - - const validationRef = useRef(validation); - validationRef.current = validation; + const clearSampleDocumentsRef = useCurrentValueRef(clearSampleDocuments); + const validatorChangedRef = useCurrentValueRef(validatorChanged); + const saveValidationRef = useCurrentValueRef(saveValidation); + const validationRef = useCurrentValueRef(validation); const trackValidator = useCallback( (validator: string) => { @@ -218,11 +211,14 @@ export const ValidationEditor: React.FunctionComponent< ); const debounceValidatorChanged = useMemo(() => { + // Ref value is not used directly for rendering, it's part of the callback + // action returned by this memo + // eslint-disable-next-line react-hooks/refs return debounce((validator: string) => { clearSampleDocumentsRef.current(); trackValidator(validator); }, 750); - }, [trackValidator]); + }, [clearSampleDocumentsRef, trackValidator]); useEffect(() => { return () => { @@ -235,7 +231,7 @@ export const ValidationEditor: React.FunctionComponent< validatorChangedRef.current(validator); debounceValidatorChanged(validator); }, - [debounceValidatorChanged] + [debounceValidatorChanged, validatorChangedRef] ); const darkMode = useDarkMode(); @@ -254,7 +250,7 @@ export const ValidationEditor: React.FunctionComponent< } saveValidationRef.current(validationRef.current); - }, [showConfirmation]); + }, [saveValidationRef, validationRef]); const isEmpty = useMemo(() => { if (!validation.validator || validation.validator.length === 0) return true; diff --git a/packages/compass-serverstats/src/components/index.tsx b/packages/compass-serverstats/src/components/index.tsx index decab26845f..4855ecb090a 100644 --- a/packages/compass-serverstats/src/components/index.tsx +++ b/packages/compass-serverstats/src/components/index.tsx @@ -1,6 +1,6 @@ import './index.less'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Banner, LeafyGreenProvider, @@ -9,6 +9,7 @@ import { spacing, palette, useScrollbars, + useInitialValue, } from '@mongodb-js/compass-components'; import GraphsComponent from './server-stats-graphs-component'; @@ -118,7 +119,7 @@ function PerformancePanelMsgs() { * Renders the entire performance tab, including charts and lists. */ function PerformanceComponent() { - const eventDispatcher = useRef(realTimeDispatcher()); + const eventDispatcher = useInitialValue(realTimeDispatcher()); const connectionInfoRef = useConnectionInfoRef(); useTrackOnChange( @@ -140,11 +141,11 @@ function PerformanceComponent() { return (
- +
- +
diff --git a/packages/compass-telemetry/package.json b/packages/compass-telemetry/package.json index 8932d0e8427..36293fa1c5d 100644 --- a/packages/compass-telemetry/package.json +++ b/packages/compass-telemetry/package.json @@ -50,11 +50,12 @@ "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." }, "dependencies": { - "@mongodb-js/compass-logging": "^1.7.24", "@mongodb-js/compass-app-registry": "^9.4.28", + "@mongodb-js/compass-components": "^1.59.0", + "@mongodb-js/compass-logging": "^1.7.24", + "@mongodb-js/mdb-experiment-js": "1.9.0", "hadron-ipc": "^3.5.22", - "react": "^17.0.2", - "@mongodb-js/mdb-experiment-js": "1.9.0" + "react": "^17.0.2" }, "devDependencies": { "@mongodb-js/eslint-config-compass": "^1.4.12", diff --git a/packages/compass-telemetry/src/experimentation-provider.tsx b/packages/compass-telemetry/src/experimentation-provider.tsx index ae74459d727..7eaf6377c91 100644 --- a/packages/compass-telemetry/src/experimentation-provider.tsx +++ b/packages/compass-telemetry/src/experimentation-provider.tsx @@ -1,7 +1,8 @@ -import React, { createContext, useContext, useRef } from 'react'; +import React, { createContext, useContext } from 'react'; import type { types } from '@mongodb-js/mdb-experiment-js'; import type { typesReact } from '@mongodb-js/mdb-experiment-js/react'; import type { ExperimentTestName } from './growth-experiments'; +import { useInitialValue } from '@mongodb-js/compass-components'; type UseAssignmentHook = ( experimentName: ExperimentTestName, @@ -79,19 +80,23 @@ export const CompassExperimentationProvider: React.FC<{ getAssignment, useTrackInSample, }) => { - // Use useRef to keep the functions up-to-date; Use mutation pattern to maintain the - // same object reference to prevent unnecessary re-renders of consuming components - const { current: contextValue } = useRef({ + // Use mutation pattern to maintain the same object reference to prevent + // unnecessary re-renders of consuming components + const contextValue = useInitialValue({ + useAssignment, + assignExperiment, + getAssignment, + useTrackInSample, + }); + // We use useInitialValue to keep a stable object reference, but then we + // directly assign the values to it to make they are always up to date with + // what's being passed + Object.assign(contextValue, { useAssignment, assignExperiment, getAssignment, useTrackInSample, }); - contextValue.useAssignment = useAssignment; - contextValue.assignExperiment = assignExperiment; - contextValue.getAssignment = getAssignment; - contextValue.useTrackInSample = useTrackInSample; - return ( {children} diff --git a/packages/compass-telemetry/src/provider.tsx b/packages/compass-telemetry/src/provider.tsx index 0892ea901e5..b76cca5bea7 100644 --- a/packages/compass-telemetry/src/provider.tsx +++ b/packages/compass-telemetry/src/provider.tsx @@ -6,6 +6,10 @@ import type { TrackFunction } from './types'; import type { ExperimentTestName } from './growth-experiments'; import { ExperimentationContext } from './experimentation-provider'; import type { types } from '@mongodb-js/mdb-experiment-js'; +import { + useCurrentValueRef, + useInitialValue, +} from '@mongodb-js/compass-components'; const noop = () => { // noop @@ -23,14 +27,14 @@ export const TelemetryProvider: React.FC<{ options: Omit; }> = ({ options, children }) => { const logger = useLogger('COMPASS-TELEMETRY'); - const trackFn = useRef( - createTrack({ + const trackFn = useInitialValue(() => { + return createTrack({ logger, ...options, - }) - ); + }); + }); return ( - + {children} ); @@ -114,8 +118,7 @@ export function useTrackOnChange( dependencies: unknown[], options: { skipOnMount: boolean } = { skipOnMount: false } ) { - const onChangeRef = React.useRef(onChange); - onChangeRef.current = onChange; + const onChangeRef = useCurrentValueRef(onChange); const track = useTelemetry(); const initialRef = useRef(true); React.useEffect(() => { diff --git a/packages/compass-web/sandbox/sandbox-atlas-sign-in.tsx b/packages/compass-web/sandbox/sandbox-atlas-sign-in.tsx index 51c5c964bc0..ac5faf4c122 100644 --- a/packages/compass-web/sandbox/sandbox-atlas-sign-in.tsx +++ b/packages/compass-web/sandbox/sandbox-atlas-sign-in.tsx @@ -78,6 +78,8 @@ export function useAtlasProxySignIn(): AtlasLoginReturnValue { null ); + // Global is modified only for local dev convenience + // eslint-disable-next-line react-hooks/immutability const signIn = ((window as any).__signIn = useCallback(async () => { try { const { projectId } = await fetch('/authenticate', { @@ -96,6 +98,8 @@ export function useAtlasProxySignIn(): AtlasLoginReturnValue { } }, [])); + // Ditto + // eslint-disable-next-line react-hooks/immutability const signOut = ((window as any).__signOut = useCallback(() => { return fetch('/logout').then( () => { diff --git a/packages/compass-web/src/entrypoint.tsx b/packages/compass-web/src/entrypoint.tsx index 830c297ed22..599319b205c 100644 --- a/packages/compass-web/src/entrypoint.tsx +++ b/packages/compass-web/src/entrypoint.tsx @@ -22,7 +22,12 @@ import { DropNamespacePlugin, RenameCollectionPlugin, } from '@mongodb-js/compass-databases-collections'; -import { CompassComponentsProvider, css } from '@mongodb-js/compass-components'; +import { + CompassComponentsProvider, + css, + useCurrentValueRef, + useInitialValue, +} from '@mongodb-js/compass-components'; import { CollectionTabsProvider, WorkspaceTab as CollectionWorkspace, @@ -480,7 +485,7 @@ const CompassWeb = ({ projectId, darkMode, initialAutoconnectId, - initialWorkspace, + initialWorkspace: _initialWorkspace, onActiveWorkspaceTabChange, initialPreferences, atlasCloudFeatureFlags, @@ -491,7 +496,7 @@ const CompassWeb = ({ onFailToLoadConnections, onBeforeUnloadCallbackRequest, }: CompassWebProps) => { - const appRegistry = useRef(new AppRegistry()); + const appRegistry = useInitialValue(new AppRegistry()); const logger = useCompassWebLogger({ onLog, onDebug, @@ -501,20 +506,19 @@ const CompassWeb = ({ atlasCloudFeatureFlags ); // TODO (COMPASS-9565): My Queries feature flag will be used to conditionally provide storage providers - const initialWorkspaceRef = useRef(initialWorkspace); - const initialWorkspaceTabsRef = useRef( - initialWorkspaceRef.current ? [initialWorkspaceRef.current] : [] + const initialWorkspace = useInitialValue(_initialWorkspace); + const initialWorkspaceTabs = useInitialValue(() => + initialWorkspace ? [initialWorkspace] : [] ); const autoconnectId = - initialWorkspaceRef.current && 'connectionId' in initialWorkspaceRef.current - ? initialWorkspaceRef.current.connectionId + initialWorkspace && 'connectionId' in initialWorkspace + ? initialWorkspace.connectionId : initialAutoconnectId ?? undefined; - const onTrackRef = useRef(onTrack); - onTrackRef.current = onTrack; + const onTrackRef = useCurrentValueRef(onTrack); - const telemetryOptions = useRef({ + const telemetryOptions = useInitialValue({ sendTrack: (event: string, properties: Record | undefined) => { void onTrackRef.current?.(event, properties || {}); }, @@ -523,11 +527,11 @@ const CompassWeb = ({ }); return ( - + - + @@ -580,9 +584,7 @@ const CompassWeb = ({ (); - if (!loggerAndTelemetryRef.current) { - loggerAndTelemetryRef.current = new CompassWebLogger( - 'COMPASS-WEB', - callbackRef - ); - } - return loggerAndTelemetryRef.current; + const callbackRef = useCurrentValueRef(callbacks); + const loggerAndTelemetry = useInitialValue(() => { + return new CompassWebLogger('COMPASS-WEB', callbackRef); + }); + return loggerAndTelemetry; } diff --git a/packages/compass-welcome/src/components/connection-plug.tsx b/packages/compass-welcome/src/components/connection-plug.tsx index 0c317781091..59c40d2cf9c 100644 --- a/packages/compass-welcome/src/components/connection-plug.tsx +++ b/packages/compass-welcome/src/components/connection-plug.tsx @@ -88,6 +88,9 @@ function LightningSparks({ // Shows a plug that animates through the connection process. export function ConnectionPlug() { const isConnected = useIsAConnectionConnected(); + // Purity rule is mostly relevant for values directly rendered on the screen, + // here we use it just to keep track of animation + // eslint-disable-next-line react-hooks/purity const animationStartTime = useRef(Date.now()); const animationFrameRef = useRef(null); diff --git a/packages/compass-workspaces/src/components/index.tsx b/packages/compass-workspaces/src/components/index.tsx index 12c0a5f856b..97214082351 100644 --- a/packages/compass-workspaces/src/components/index.tsx +++ b/packages/compass-workspaces/src/components/index.tsx @@ -1,5 +1,11 @@ -import React, { useEffect, useRef } from 'react'; -import { css, cx, palette, useDarkMode } from '@mongodb-js/compass-components'; +import React, { useEffect } from 'react'; +import { + css, + cx, + palette, + useCurrentValueRef, + useDarkMode, +} from '@mongodb-js/compass-components'; import type { CollectionTabInfo } from '../stores/workspaces'; import { getActiveTab, @@ -102,11 +108,10 @@ const WorkspacesWithSidebar: React.FunctionComponent< renderModals, }) => { const darkMode = useDarkMode(); - const onChange = useRef(onActiveWorkspaceTabChange); - onChange.current = onActiveWorkspaceTabChange; + const onChange = useCurrentValueRef(onActiveWorkspaceTabChange); useEffect(() => { onChange.current(activeTab, activeTabCollectionInfo); - }, [activeTab, activeTabCollectionInfo]); + }, [activeTab, activeTabCollectionInfo, onChange]); return (
boolean; @@ -77,14 +78,13 @@ function useOnTabDestroyHandler( handler: WorkspaceDestroyHandler ) { const tabId = useWorkspaceTabId(); - const handlerRef = useRef(handler); - handlerRef.current = handler; + const handlerRef = useCurrentValueRef(handler); useEffect(() => { const onClose: WorkspaceDestroyHandler = () => { return handlerRef.current(); }; return setTabDestroyHandler(type, tabId, onClose); - }, [type, tabId]); + }, [type, tabId, handlerRef]); } /** diff --git a/packages/compass-workspaces/src/components/workspaces-provider.tsx b/packages/compass-workspaces/src/components/workspaces-provider.tsx index f88b99f04bd..35e19ed6b91 100644 --- a/packages/compass-workspaces/src/components/workspaces-provider.tsx +++ b/packages/compass-workspaces/src/components/workspaces-provider.tsx @@ -1,6 +1,7 @@ -import React, { useContext, useRef } from 'react'; +import React, { useContext } from 'react'; import type { AnyWorkspace } from '../'; import type { WorkspacePlugin } from '../types'; +import { useInitialValue } from '@mongodb-js/compass-components'; export type AnyWorkspacePlugin = | WorkspacePlugin<'Welcome'> @@ -17,9 +18,9 @@ const WorkspacesContext = React.createContext([]); export const WorkspacesProvider: React.FunctionComponent<{ value: AnyWorkspacePlugin[]; }> = ({ value, children }) => { - const valueRef = useRef(value); + const initialValue = useInitialValue(value); return ( - + {children} ); @@ -27,7 +28,7 @@ export const WorkspacesProvider: React.FunctionComponent<{ export const useWorkspacePlugins = () => { const workspaces = useContext(WorkspacesContext); - const workspacePlugins = useRef({ + const workspacePlugins = useInitialValue({ hasWorkspacePlugin: (name: T) => { return workspaces.some((ws) => ws.name === name); }, @@ -44,5 +45,5 @@ export const useWorkspacePlugins = () => { return plugin as unknown as WorkspacePlugin; }, }); - return workspacePlugins.current; + return workspacePlugins; }; diff --git a/packages/compass-workspaces/src/index.spec.tsx b/packages/compass-workspaces/src/index.spec.tsx index 7ae34918358..6afd3383dff 100644 --- a/packages/compass-workspaces/src/index.spec.tsx +++ b/packages/compass-workspaces/src/index.spec.tsx @@ -65,6 +65,8 @@ describe('WorkspacesPlugin', function () { async function renderPlugin() { function OpenWorkspaceFnsGetter() { + // exposed outside of render loop for testing + // eslint-disable-next-line react-hooks/globals openFns = useOpenWorkspace(); return null; } diff --git a/packages/compass-workspaces/src/services/workspaces-storage-desktop.tsx b/packages/compass-workspaces/src/services/workspaces-storage-desktop.tsx index 8ce30b24eb7..ae79db169e7 100644 --- a/packages/compass-workspaces/src/services/workspaces-storage-desktop.tsx +++ b/packages/compass-workspaces/src/services/workspaces-storage-desktop.tsx @@ -1,12 +1,13 @@ -import React, { useRef } from 'react'; +import React from 'react'; import { FileUserData, type IUserData } from '@mongodb-js/compass-user-data'; import { WorkspacesStorageServiceContext } from './workspaces-storage'; import { WorkspacesStateSchema } from '../types'; import { EJSON } from 'bson'; +import { useInitialValue } from '@mongodb-js/compass-components'; export const WorkspacesStorageServiceProviderDesktop: React.FunctionComponent = ({ children }) => { - const storageRef = useRef>( + const storageRef = useInitialValue>( new FileUserData(WorkspacesStateSchema, 'WorkspacesState', { serialize: (content) => EJSON.stringify(content, { @@ -16,7 +17,7 @@ export const WorkspacesStorageServiceProviderDesktop: React.FunctionComponent = }) as IUserData ); return ( - + {children} ); diff --git a/packages/compass-workspaces/src/services/workspaces-storage-web.tsx b/packages/compass-workspaces/src/services/workspaces-storage-web.tsx index eb67c2c4bb1..069b2bddc41 100644 --- a/packages/compass-workspaces/src/services/workspaces-storage-web.tsx +++ b/packages/compass-workspaces/src/services/workspaces-storage-web.tsx @@ -1,8 +1,9 @@ -import React, { useRef } from 'react'; +import React from 'react'; import { AtlasUserData, type IUserData } from '@mongodb-js/compass-user-data'; import { WorkspacesStorageServiceContext } from './workspaces-storage'; import { WorkspacesStateSchema } from '../types'; import { EJSON } from 'bson'; +import { useInitialValue } from '@mongodb-js/compass-components'; export const WorkspacesStorageServiceProviderWeb: React.FunctionComponent<{ orgId: string; @@ -13,7 +14,7 @@ export const WorkspacesStorageServiceProviderWeb: React.FunctionComponent<{ options?: RequestInit ) => Promise; }> = ({ orgId, projectId, getResourceUrl, authenticatedFetch, children }) => { - const storageRef = useRef>( + const storageRef = useInitialValue>( new AtlasUserData(WorkspacesStateSchema, 'WorkspacesState', { orgId, projectId, @@ -27,7 +28,7 @@ export const WorkspacesStorageServiceProviderWeb: React.FunctionComponent<{ }) ); return ( - + {children} ); diff --git a/packages/compass/src/app/components/entrypoint.tsx b/packages/compass/src/app/components/entrypoint.tsx index ec6911bdd65..b69b01f5853 100644 --- a/packages/compass/src/app/components/entrypoint.tsx +++ b/packages/compass/src/app/components/entrypoint.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React from 'react'; import { AppRegistryProvider } from '@mongodb-js/compass-app-registry'; import { defaultPreferencesInstance } from 'compass-preferences-model'; import { PreferencesProvider } from 'compass-preferences-model/provider'; @@ -32,20 +32,21 @@ import { } from '@mongodb-js/compass-telemetry'; import { DataModelStorageServiceProviderElectron } from '@mongodb-js/compass-data-modeling/renderer'; import { WorkspacesStorageServiceProviderDesktop } from '@mongodb-js/compass-workspaces'; +import { useInitialValue } from '@mongodb-js/compass-components'; const WithPreferencesAndLoggerProviders: React.FC = ({ children }) => { - const loggerProviderValue = useRef({ + const loggerProviderValue = useInitialValue({ createLogger, }); - const preferencesProviderValue = useRef(defaultPreferencesInstance); - const telemetryOptions = useRef({ + const preferencesProviderValue = useInitialValue(defaultPreferencesInstance); + const telemetryOptions = useInitialValue({ sendTrack: createIpcSendTrack(), preferences: defaultPreferencesInstance, }); return ( - - - + + + {children} @@ -54,9 +55,9 @@ const WithPreferencesAndLoggerProviders: React.FC = ({ children }) => { }; export const WithAtlasProviders: React.FC = ({ children }) => { - const authService = useRef(new CompassAtlasAuthService()); + const authService = useInitialValue(() => new CompassAtlasAuthService()); return ( - + { }; export const WithStorageProviders: React.FC = ({ children }) => { - const pipelineStorage = useRef({ + const pipelineStorage = useInitialValue({ getStorage(options) { return createElectronPipelineStorage({ basepath: options?.basePath }); }, }); - const favoriteQueryStorage = useRef({ + const favoriteQueryStorage = useInitialValue({ getStorage(options) { return createElectronFavoriteQueryStorage({ basepath: options?.basepath, }); }, }); - const recentQueryStorage = useRef({ + const recentQueryStorage = useInitialValue({ getStorage(options) { return createElectronRecentQueryStorage({ basepath: options?.basepath }); }, }); return ( - - - + + + {children} diff --git a/packages/connection-form/src/components/advanced-options-tabs/advanced-tab/url-options-list-editor.tsx b/packages/connection-form/src/components/advanced-options-tabs/advanced-tab/url-options-list-editor.tsx index 779cffc70d6..31b76b9e4ab 100644 --- a/packages/connection-form/src/components/advanced-options-tabs/advanced-tab/url-options-list-editor.tsx +++ b/packages/connection-form/src/components/advanced-options-tabs/advanced-tab/url-options-list-editor.tsx @@ -1,5 +1,5 @@ import type { ChangeEvent } from 'react'; -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback } from 'react'; import { TextInput, Select, @@ -8,6 +8,7 @@ import { css, spacing, ListEditor, + useSyncStateOnPropChange, } from '@mongodb-js/compass-components'; import type ConnectionStringUrl from 'mongodb-connection-string-url'; import type { MongoClientOptions } from 'mongodb'; @@ -66,8 +67,11 @@ function UrlOptionsListEditor({ updateConnectionFormField: UpdateConnectionFormField; connectionStringUrl: ConnectionStringUrl; }): React.ReactElement { - const [options, setOptions] = React.useState[]>([]); - useEffect(() => { + const [options, setOptions] = React.useState[]>(() => { + return appendEmptyOption(getUrlOptions(connectionStringUrl)); + }); + + useSyncStateOnPropChange(() => { const newOptions = appendEmptyOption(getUrlOptions(connectionStringUrl)); setOptions(newOptions); }, [connectionStringUrl]); diff --git a/packages/connection-form/src/components/advanced-options-tabs/authentication-tab/authentication-gssapi.tsx b/packages/connection-form/src/components/advanced-options-tabs/authentication-tab/authentication-gssapi.tsx index 1d4d803011f..ad9c2b91d41 100644 --- a/packages/connection-form/src/components/advanced-options-tabs/authentication-tab/authentication-gssapi.tsx +++ b/packages/connection-form/src/components/advanced-options-tabs/authentication-tab/authentication-gssapi.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; import { FormFieldContainer, Label, @@ -55,17 +55,14 @@ function AuthenticationGSSAPI({ authMechanismProperties.get('CANONICALIZE_HOST_NAME') || 'none'; const [showPassword, setShowPassword] = useState(false); + if (!showPassword && password.length) { + setShowPassword(true); + } const showKerberosPasswordField = !!useConnectionFormSetting( 'showKerberosPasswordField' ); - useEffect(() => { - if (!showPassword && password.length) { - setShowPassword(true); - } - }, [password, showPassword, updateConnectionFormField]); - return ( <> diff --git a/packages/connection-form/src/components/advanced-options-tabs/general-tab/host-input.tsx b/packages/connection-form/src/components/advanced-options-tabs/general-tab/host-input.tsx index a3d5765af7f..ffdffd0a96e 100644 --- a/packages/connection-form/src/components/advanced-options-tabs/general-tab/host-input.tsx +++ b/packages/connection-form/src/components/advanced-options-tabs/general-tab/host-input.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { FormFieldContainer, Label, @@ -6,6 +6,7 @@ import { ListEditor, css, spacing, + useSyncStateOnPropChange, } from '@mongodb-js/compass-components'; import type ConnectionStringUrl from 'mongodb-connection-string-url'; import type { MongoClientOptions } from 'mongodb'; @@ -40,7 +41,7 @@ function HostInput({ .get('directConnection') === 'true' || (!connectionStringUrl.isSRV && hosts.length === 1); - useEffect(() => { + useSyncStateOnPropChange(() => { // Update the hosts in the state when the underlying connection string hosts // change. This can be when a user changes connections, pastes in a new // connection string, or changes a setting which also updates the hosts. diff --git a/packages/connection-form/src/components/advanced-options-tabs/ssh-tunnel-tab/proxy-and-ssh-tunnel-tab.tsx b/packages/connection-form/src/components/advanced-options-tabs/ssh-tunnel-tab/proxy-and-ssh-tunnel-tab.tsx index ec8a0782d0c..5c9004668e7 100644 --- a/packages/connection-form/src/components/advanced-options-tabs/ssh-tunnel-tab/proxy-and-ssh-tunnel-tab.tsx +++ b/packages/connection-form/src/components/advanced-options-tabs/ssh-tunnel-tab/proxy-and-ssh-tunnel-tab.tsx @@ -1,5 +1,5 @@ import type { ChangeEvent } from 'react'; -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import type { ConnectionOptions } from 'mongodb-data-service'; import { Label, @@ -125,17 +125,22 @@ function ProxyAndSshTunnelTab({ connectionStringUrl, connectionOptions ); - - const options = [...tabOptions]; const showProxySettings = useConnectionFormSetting('showProxySettings'); - if (showProxySettings) { - options.push({ - title: 'Application-level Proxy', - id: 'app-proxy', - type: 'app-proxy', - component: AppProxy, - }); - } + const options = useMemo(() => { + if (showProxySettings) { + return [ + ...tabOptions, + { + title: 'Application-level Proxy', + id: 'app-proxy', + type: 'app-proxy', + component: AppProxy, + } as const, + ]; + } else { + return [...tabOptions]; + } + }, [showProxySettings]); const selectedOptionIndex = options.findIndex((x) => x.type === selectedTunnelType) ?? 0; @@ -177,15 +182,15 @@ function ProxyAndSshTunnelTab({ ); const optionSelected = useCallback( - (event: ChangeEvent) => { - event.preventDefault(); - const item = options.find(({ id }) => id === event.target.value); + (evt: ChangeEvent) => { + evt.preventDefault(); + const item = options.find(({ id }) => id === evt.target.value); if (item) { handleOptionChanged(selectedOption.type, item.type); setSelectedOption(item); } }, - [selectedOption, handleOptionChanged] + [handleOptionChanged, options, selectedOption.type] ); const TunnelContent = selectedOption.component; diff --git a/packages/connection-form/src/components/connection-string-input.tsx b/packages/connection-form/src/components/connection-string-input.tsx index 58da2dd06c7..9f6dd6549bd 100644 --- a/packages/connection-form/src/components/connection-string-input.tsx +++ b/packages/connection-form/src/components/connection-string-input.tsx @@ -1,10 +1,4 @@ -import React, { - Fragment, - useCallback, - useEffect, - useState, - useRef, -} from 'react'; +import React, { Fragment, useCallback, useState, useRef } from 'react'; import { InlineInfoLink, Label, @@ -13,6 +7,7 @@ import { spacing, css, showConfirmation, + useSyncStateOnPropChange, } from '@mongodb-js/compass-components'; import { redactConnectionString } from 'mongodb-connection-string-url'; import type { UpdateConnectionFormField } from '../hooks/use-connect-form'; @@ -92,23 +87,15 @@ function ConnectionStringInput({ const textAreaEl = useRef(null); const [editingConnectionString, setEditingConnectionString] = useState(connectionString); + const [isTextAreaFocused, setIsTextAreaFocused] = useState(false); - const [isTextAreaFocussed, setIsTextAreaFocussed] = useState(false); - - useEffect(() => { - // If the user isn't actively editing the connection string and it - // changes (form action/new connection) we update the string. - if ( - editingConnectionString !== connectionString && - (!textAreaEl.current || textAreaEl.current !== document.activeElement) - ) { + // If the user isn't actively editing the connection string and it changes + // (form action/new connection) we update the string + useSyncStateOnPropChange(() => { + if (!isTextAreaFocused && connectionString !== editingConnectionString) { setEditingConnectionString(connectionString); } - }, [ - connectionString, - enableEditingConnectionString, - editingConnectionString, - ]); + }, [isTextAreaFocused, connectionString, editingConnectionString]); const onKeyPressedConnectionString = useCallback( (event: React.KeyboardEvent) => { @@ -138,7 +125,7 @@ function ConnectionStringInput({ ); const displayedConnectionString = - enableEditingConnectionString && isTextAreaFocussed + enableEditingConnectionString && isTextAreaFocused ? editingConnectionString : hidePasswordInConnectionString(editingConnectionString); @@ -158,7 +145,7 @@ function ConnectionStringInput({ } setEnableEditingConnectionString(true); }, - [setEnableEditingConnectionString, showConfirmation] + [setEnableEditingConnectionString] ); return ( @@ -198,8 +185,8 @@ function ConnectionStringInput({