From 47a574a3b1c35261062fe6e12d47fd7b00263b29 Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 23 Aug 2026 08:53:35 +0300 Subject: [PATCH 1/4] chore(eslint): update React Hooks configuration --- bun.lock | 34 +++++++++++++++++++++++++++++----- eslint.config.js | 19 ++++++++++++++++++- package.json | 2 +- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 82298d0..2604ff9 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,7 @@ "@vitest/coverage-v8": "^4.1.0", "@vitest/ui": "^4.1.0", "eslint": "^9.21.0", - "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.4.19", "gh-pages": "^6.3.0", "globals": "^15.15.0", @@ -81,7 +81,7 @@ "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], - "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], @@ -565,7 +565,7 @@ "eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": "bin/eslint.js" }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="], - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "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" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], @@ -657,6 +657,10 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], @@ -1007,10 +1011,24 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@babel/core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@babel/generator/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@babel/parser/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@babel/template/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@babel/traverse/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], "@emotion/babel-plugin/stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], @@ -1041,6 +1059,10 @@ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@types/babel__core/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@types/babel__template/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], @@ -1059,8 +1081,6 @@ "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "magicast/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "magicast/@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "make-dir/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -1089,6 +1109,10 @@ "vitest/vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], + "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "axios/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], diff --git a/eslint.config.js b/eslint.config.js index 092408a..dc8f33b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,10 +1,11 @@ import js from '@eslint/js' +import { defineConfig } from 'eslint/config' import globals from 'globals' import reactHooks from 'eslint-plugin-react-hooks' import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' -export default tseslint.config( +export default defineConfig( { ignores: ['dist'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], @@ -13,16 +14,32 @@ export default tseslint.config( ecmaVersion: 2020, globals: globals.browser, }, + settings: { + 'react-hooks': { + additionalEffectHooks: '(useAsync)', + }, + }, plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, }, rules: { ...reactHooks.configs.recommended.rules, + 'react-hooks/exhaustive-deps': 'warn', 'react-refresh/only-export-components': [ 'warn', { allowConstantExport: true }, ], }, }, + { + // These components intentionally synchronize editable state with external data. + files: [ + 'src/features/config/ConfigPanel.tsx', + 'src/features/salary-summary/hooks/usePayTableVM.ts', + ], + rules: { + 'react-hooks/set-state-in-effect': 'off', + }, + }, ) diff --git a/package.json b/package.json index f4587ff..b469feb 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@vitest/coverage-v8": "^4.1.0", "@vitest/ui": "^4.1.0", "eslint": "^9.21.0", - "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.4.19", "gh-pages": "^6.3.0", "globals": "^15.15.0", From 2f80f927ad84e59b08014001c60a9b677892bb4e Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 23 Aug 2026 08:53:51 +0300 Subject: [PATCH 2/4] refactor(hooks): adopt effect events in useAsync --- src/hooks/useAsync.ts | 25 +++++----- src/pages/DailyPage.tsx | 51 +++++++++++--------- src/test/ui/hooks/useAsync.test.tsx | 73 +++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 35 deletions(-) create mode 100644 src/test/ui/hooks/useAsync.test.tsx diff --git a/src/hooks/useAsync.ts b/src/hooks/useAsync.ts index 62a6749..a1dc8e3 100644 --- a/src/hooks/useAsync.ts +++ b/src/hooks/useAsync.ts @@ -1,31 +1,28 @@ -import { useEffect, useRef, DependencyList } from "react"; +import { useEffect, useEffectEvent, type DependencyList } from "react"; export const useAsync = ( asyncRequest: () => Promise, + deps: DependencyList, onResult: (response: T) => void, - deps: DependencyList = [], cleanup?: () => void, ) => { - const asyncRequestRef = useRef(asyncRequest); - const onResultRef = useRef(onResult); - const cleanupRef = useRef(cleanup); + const asyncRequestEvent = useEffectEvent(asyncRequest); + const onResultEvent = useEffectEvent(onResult); + const cleanupEvent = useEffectEvent(() => cleanup?.()); - asyncRequestRef.current = asyncRequest; - onResultRef.current = onResult; - cleanupRef.current = cleanup; - - // deps controls WHEN to fire; refs ensure no stale closures on the callbacks. - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { let isActive = true; - asyncRequestRef.current().then((result) => { - if (isActive) onResultRef.current(result); + void asyncRequestEvent().then((result) => { + if (isActive) onResultEvent(result); }); return () => { isActive = false; - cleanupRef.current?.(); + cleanupEvent(); }; + + // Dependencies are statically validated at useAsync call sites. + // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); }; diff --git a/src/pages/DailyPage.tsx b/src/pages/DailyPage.tsx index d050278..ec2ee2e 100644 --- a/src/pages/DailyPage.tsx +++ b/src/pages/DailyPage.tsx @@ -32,12 +32,13 @@ import { DomainContextType } from "@/app"; import { hebcalService, analyticsService } from "@/services"; import { ErrorBoundary, FeatureErrorFallback } from "@/layout"; +const calendarApi = hebcalService(); + export const DailyPage = ({ domain }: { domain: DomainContextType }) => { const { t } = useTranslation("work-table"); const { dateService } = domain.services; const { isMobile } = useDeviceType(); - const call = hebcalService(); const { year, month, baseRate, reset } = useGlobalState(); const { workDays, generate } = useWorkDays(); @@ -50,33 +51,41 @@ export const DailyPage = ({ domain }: { domain: DomainContextType }) => { const { loading, callEndPoint, cancelEndPoint } = useFetch(); + const handleCalendarResult = ({ + data, + error, + }: ApiResponse) => { + if (data) { + generate(year, month, data); + reset(); + setError(undefined); + return; + } + + const description = error ?? "hebcal fetch failed"; + + analyticsService.track({ + name: "exception", + params: { + description, + fatal: false, + error_type: "hebcal_api_error", + }, + }); + + setError(description); + }; + useAsync>( () => { const { startDate, endDate } = dateService.getDatesRange(year, month); return callEndPoint( - call.getData(startDate, endDate), + calendarApi.getData(startDate, endDate), buildEventMap, ); }, - ({ data, error }) => { - if (data) { - // console.log(data); - generate(year, month, data); - reset(); - setError(undefined); - return; - } - analyticsService.track({ - name: "exception", - params: { - description: error ?? "hebcal fetch failed", - fatal: false, - error_type: "hebcal_api_error", - }, - }); - setError(error); - }, - [year, month], + [dateService, year, month, callEndPoint], + handleCalendarResult, cancelEndPoint, ); diff --git a/src/test/ui/hooks/useAsync.test.tsx b/src/test/ui/hooks/useAsync.test.tsx new file mode 100644 index 0000000..4dd0acf --- /dev/null +++ b/src/test/ui/hooks/useAsync.test.tsx @@ -0,0 +1,73 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { useAsync } from "@/hooks/useAsync"; + +describe("useAsync", () => { + it("runs the request initially and when a dependency changes", async () => { + const onResult = vi.fn(); + const request = vi.fn((value: number) => Promise.resolve(value)); + + const { rerender } = renderHook( + ({ dependency }) => + useAsync( + () => request(dependency), + [dependency], + onResult, + ), + { initialProps: { dependency: 1 } }, + ); + + await waitFor(() => expect(onResult).toHaveBeenLastCalledWith(1)); + + rerender({ dependency: 2 }); + + await waitFor(() => expect(onResult).toHaveBeenLastCalledWith(2)); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("uses the latest result callback without repeating the request", async () => { + let resolveRequest: (value: string) => void = () => undefined; + const request = vi.fn( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }), + ); + const firstOnResult = vi.fn(); + const latestOnResult = vi.fn(); + + const { rerender } = renderHook( + ({ onResult }) => useAsync(request, [], onResult), + { initialProps: { onResult: firstOnResult } }, + ); + + rerender({ onResult: latestOnResult }); + + await act(async () => resolveRequest("calendar")); + + expect(firstOnResult).not.toHaveBeenCalled(); + expect(latestOnResult).toHaveBeenCalledWith("calendar"); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("ignores pending results and runs cleanup after unmount", async () => { + let resolveRequest: (value: string) => void = () => undefined; + const request = () => + new Promise((resolve) => { + resolveRequest = resolve; + }); + const onResult = vi.fn(); + const cleanup = vi.fn(); + + const { unmount } = renderHook(() => + useAsync(request, [], onResult, cleanup), + ); + + unmount(); + await act(async () => resolveRequest("late result")); + + expect(cleanup).toHaveBeenCalledOnce(); + expect(onResult).not.toHaveBeenCalled(); + }); +}); From 34986393a36e92c4329270ba8fc30f808cd25d19 Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 23 Aug 2026 08:54:03 +0300 Subject: [PATCH 3/4] fix(config): reject unsupported future years --- src/features/config/ConfigPanel.tsx | 13 ++++-- src/i18n/locales/en/common.json | 1 + src/i18n/locales/he/common.json | 1 + src/test/ui/components/ConfigPanel.test.tsx | 50 ++++++++++++++++++++- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/features/config/ConfigPanel.tsx b/src/features/config/ConfigPanel.tsx index b5ace62..3eec9c7 100644 --- a/src/features/config/ConfigPanel.tsx +++ b/src/features/config/ConfigPanel.tsx @@ -114,11 +114,18 @@ export const ConfigPanel = ({ domain, mode }: ConfigPanelProps) => { const availableMonths = monthResolver.getAvailableMonthOptions(year); + const currentYear = monthResolver.getCurrentYear(); const parsedYear = Number(inputsValues.yearInput); - const yearError = !isNaN(parsedYear) && parsedYear < SYSTEM_START_YEAR; - const yearHelperText = yearError + const yearBelowMinimum = + !Number.isNaN(parsedYear) && parsedYear < SYSTEM_START_YEAR; + const yearAboveMaximum = + !Number.isNaN(parsedYear) && parsedYear > currentYear; + const yearError = yearBelowMinimum || yearAboveMaximum; + const yearHelperText = yearBelowMinimum ? t("config.year_min_error", { year: SYSTEM_START_YEAR }) - : ""; + : yearAboveMaximum + ? t("config.year_max_error", { year: currentYear }) + : ""; const helperTextBaseRate = (): string => { if (baseRate === 0) { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index b53e140..1e137e5 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -19,6 +19,7 @@ "standard_hours_helper": "Default: 6.67", "base_rate_label": "Hourly Rate", "year_min_error": "Minimum supported year is {{year}}", + "year_max_error": "Maximum supported year is {{year}}", "base_rate_helper_daily": "Enter hourly rate to display daily or monthly salary", "base_rate_helper_monthly": "Monthly salary calculation requires setting hourly rate", "calculation_based_on": "Calculation based on {{monthName}} {{year}}" diff --git a/src/i18n/locales/he/common.json b/src/i18n/locales/he/common.json index ceacd1c..0d18de1 100644 --- a/src/i18n/locales/he/common.json +++ b/src/i18n/locales/he/common.json @@ -19,6 +19,7 @@ "standard_hours_helper": "ברירת מחדל: {{standardHours}}", "base_rate_label": "שכר שעתי", "year_min_error": "השנה המינימלית הנתמכת היא {{year}}", + "year_max_error": "השנה המקסימלית הנתמכת היא {{year}}", "base_rate_helper_daily": "יש להזין שכר שעתי להצגת שכר יומי או חודשי", "base_rate_helper_monthly": "חישוב שכר חודשי מחייב הגדרת שכר שעתי", "calculation_based_on": "החישוב מבוסס על {{monthName}} {{year}}" diff --git a/src/test/ui/components/ConfigPanel.test.tsx b/src/test/ui/components/ConfigPanel.test.tsx index 29db6a2..951738b 100644 --- a/src/test/ui/components/ConfigPanel.test.tsx +++ b/src/test/ui/components/ConfigPanel.test.tsx @@ -1,5 +1,12 @@ -import { describe, it, expect } from "vitest"; -import { renderWithProviders, screen, createMockGlobalState, waitFor } from "@/test/ui/utils"; +import { describe, it, expect, vi } from "vitest"; +import { + act, + createMockGlobalState, + fireEvent, + renderWithProviders, + screen, + waitFor, +} from "@/test/ui/utils"; import userEvent from "@testing-library/user-event"; import { ConfigPanel } from "@/features/config/ConfigPanel"; import { pipelineInstance } from "@/test/ui/utils/setup-domain"; @@ -237,6 +244,45 @@ describe("ConfigPanel", () => { }, { timeout: 200 }); }); + it("should show an error for a year after the current year", () => { + vi.useFakeTimers(); + + try { + const currentYear = mockDomain.resolvers.monthResolver.getCurrentYear(); + const { store } = renderWithProviders( + , + { + preloadedState: { + global: createMockGlobalState({ + config: { + year: 2024, + month: 1, + standardHours: 6.67, + baseRate: 50, + }, + }), + }, + }, + ); + + const yearInput = screen.getByLabelText("שנה"); + fireEvent.change(yearInput, { + target: { value: String(currentYear + 1) }, + }); + + expect( + screen.getByText(`השנה המקסימלית הנתמכת היא ${currentYear}`), + ).toBeInTheDocument(); + expect(yearInput).toHaveAttribute("aria-invalid", "true"); + + act(() => vi.advanceTimersByTime(500)); + + expect(store.getState().global.config.year).toBe(2024); + } finally { + vi.useRealTimers(); + } + }); + it("should show helper text for zero base rate in daily mode", () => { renderWithProviders(, { preloadedState: { From 1f081a05ebcf4dd5d6f1e75525f5e6c47e2fa424 Mon Sep 17 00:00:00 2001 From: David Maman Date: Sun, 23 Aug 2026 08:54:14 +0300 Subject: [PATCH 4/4] test: hoist application domain mock --- src/test/ui/utils/setup-domain.ts | 47 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/src/test/ui/utils/setup-domain.ts b/src/test/ui/utils/setup-domain.ts index 9ba8566..79b8924 100644 --- a/src/test/ui/utils/setup-domain.ts +++ b/src/test/ui/utils/setup-domain.ts @@ -1,4 +1,4 @@ -import { vi, beforeAll } from "vitest"; +import { vi } from "vitest"; import { buildPayMapPipeline } from "@/domain"; // Initialize domain pipeline once for all tests @@ -6,30 +6,27 @@ const pipelineInstance = buildPayMapPipeline({ timeZone: "Asia/Jerusalem", }); -// Mock the domain module before any tests run -beforeAll(() => { - vi.mock("@/app", () => ({ - domain: { - payMap: { - shiftMapBuilder: pipelineInstance.payMap.shiftMapBuilder, - dayPayMapBuilder: pipelineInstance.payMap.dayPayMapBuilder, - monthPayMapCalculator: pipelineInstance.payMap.monthPayMapCalculator, - workDaysMonthBuilder: pipelineInstance.payMap.workDaysForMonthBuilder, - }, - resolvers: { - holidayResolver: pipelineInstance.resolvers.holidayResolver, - perDiemResolver: pipelineInstance.resolvers.perDiemRateResolver, - dayInfoResolver: pipelineInstance.resolvers.workDayInfoResolver, - monthResolver: pipelineInstance.resolvers.monthResolver, - mealAllowanceRateResolver: - pipelineInstance.resolvers.mealAllowanceRateResolver, - }, - services: { - dateService: pipelineInstance.services.dateService, - shiftService: pipelineInstance.services.shiftService, - }, +vi.mock("@/app", () => ({ + domain: { + payMap: { + shiftMapBuilder: pipelineInstance.payMap.shiftMapBuilder, + dayPayMapBuilder: pipelineInstance.payMap.dayPayMapBuilder, + monthPayMapCalculator: pipelineInstance.payMap.monthPayMapCalculator, + workDaysMonthBuilder: pipelineInstance.payMap.workDaysForMonthBuilder, }, - })); -}); + resolvers: { + holidayResolver: pipelineInstance.resolvers.holidayResolver, + perDiemResolver: pipelineInstance.resolvers.perDiemRateResolver, + dayInfoResolver: pipelineInstance.resolvers.workDayInfoResolver, + monthResolver: pipelineInstance.resolvers.monthResolver, + mealAllowanceRateResolver: + pipelineInstance.resolvers.mealAllowanceRateResolver, + }, + services: { + dateService: pipelineInstance.services.dateService, + shiftService: pipelineInstance.services.shiftService, + }, + }, +})); export { pipelineInstance };