From b49a3b401aad7296e4d2b506f6aba366fa20ca79 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 24 Sep 2026 15:21:20 -0400 Subject: [PATCH 1/2] chore: upgrade eslint stack and fix React Compiler lint errors Upgrades @eslint/js 10, @eslint/compat 2, eslint-config-prettier 10, eslint-plugin-react-hooks 7 and globals 17, and drops the deprecated @types/eslint__js. react-hooks 7's recommended set adds the React Compiler rules. Both sites build with reactCompiler: true, so these are real findings, not style. Fixes for the 31 new errors: - Mounted flags (`useEffect(() => setMounted(true), [])`) become `useIsClient()`, a useSyncExternalStore hook in lib/use-is-client.ts. - Resize listeners that mirror window.innerWidth into state become `useWindowWidth()` in lib/use-window-width.ts. Hero reads its prop directly instead of copying it into state. - Forms seeded from query data, and state reset when an input changes, use the "adjust state during render" pattern guarded by the previous value, so the stale value never paints. - AdminLayout derives `loading` instead of storing it; the Stripe modal memoizes loadStripe; EventFormModal seeds the date in its initial state; verify uses window.location.assign; the judging page updates its ref in an effect instead of during render. - LazySection drops the IntersectionObserver-missing fallback (every supported browser has it) and the hacklytics countdown seeds its first tick from the initial state. - AttendeesTab: drop a useless initial assignment (no-useless-assignment). --- packages/auth/package.json | 2 +- pnpm-lock.yaml | 319 +++++++++++++++--- sites/hacklytics2027/app/page.tsx | 12 +- .../hacklytics2027/components/LazySection.tsx | 6 - .../app/(portal)/admin/judging/page.tsx | 27 +- .../app/(portal)/admin/resumes/page.tsx | 6 +- sites/mainweb/app/(portal)/club/page.tsx | 12 +- .../(portal)/hackathons/[id]/judge/page.tsx | 15 +- sites/mainweb/app/(portal)/judge/page.tsx | 7 +- sites/mainweb/app/(portal)/login/page.tsx | 4 +- sites/mainweb/app/(portal)/settings/page.tsx | 29 +- sites/mainweb/app/(portal)/submit/page.tsx | 31 +- sites/mainweb/app/(portal)/verify/page.tsx | 5 +- sites/mainweb/app/HomePageClient.tsx | 15 +- sites/mainweb/app/bootcamp/page.tsx | 11 +- sites/mainweb/app/not-found.tsx | 13 +- sites/mainweb/components/Hero/index.tsx | 7 +- sites/mainweb/components/Navbar/index.tsx | 5 +- .../admin/hackathons/AttendeesTab.tsx | 2 +- .../admin/hackathons/EditHackathonForm.tsx | 52 ++- .../components/hackathon/InterestForm.tsx | 11 +- .../mainweb/components/portal/AdminLayout.tsx | 11 +- .../components/portal/EventFormModal.tsx | 16 +- .../components/portal/MembershipTab.tsx | 11 +- .../components/portal/PortalSidebar.tsx | 7 +- .../components/portal/ResumePreview.tsx | 25 +- .../components/portal/StripePaymentModal.tsx | 18 +- sites/mainweb/lib/use-is-client.ts | 18 + sites/mainweb/lib/use-window-width.ts | 20 ++ tooling/eslint/package.json | 11 +- 30 files changed, 468 insertions(+), 260 deletions(-) create mode 100644 sites/mainweb/lib/use-is-client.ts create mode 100644 sites/mainweb/lib/use-window-width.ts diff --git a/packages/auth/package.json b/packages/auth/package.json index f582fc89..a8db62e8 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -29,7 +29,7 @@ "@typescript-eslint/parser": "^8.70.1", "@typescript-eslint/utils": "^8.70.1", "eslint": "^10.11.0", - "eslint-config-prettier": "^10.1.5", + "eslint-config-prettier": "^10.1.8", "typescript": "^7.0.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c470fcb..89a63c17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,7 +138,7 @@ importers: specifier: ^10.11.0 version: 10.11.0(jiti@2.7.0) eslint-config-prettier: - specifier: ^10.1.5 + specifier: ^10.1.8 version: 10.1.8(eslint@10.11.0(jiti@2.7.0)) typescript: specifier: ^7.0.2 @@ -445,11 +445,11 @@ importers: tooling/eslint: dependencies: '@eslint/compat': - specifier: 1.2.0 - version: 1.2.0(eslint@10.11.0(jiti@2.7.0)) + specifier: 2.1.1 + version: 2.1.1(eslint@10.11.0(jiti@2.7.0)) '@eslint/js': - specifier: 9.17.0 - version: 9.17.0 + specifier: 10.0.1 + version: 10.0.1(eslint@10.11.0(jiti@2.7.0)) '@next/eslint-plugin-next': specifier: 16.3.6 version: 16.3.6(eslint@10.11.0(jiti@2.7.0)) @@ -460,8 +460,8 @@ importers: specifier: 8.70.1 version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2) eslint-config-prettier: - specifier: 9.1.0 - version: 9.1.0(eslint@10.11.0(jiti@2.7.0)) + specifier: 10.1.8 + version: 10.1.8(eslint@10.11.0(jiti@2.7.0)) eslint-plugin-import: specifier: 2.32.0 version: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.2))(eslint@10.11.0(jiti@2.7.0)) @@ -472,14 +472,14 @@ importers: specifier: 7.37.5 version: 7.37.5(eslint@10.11.0(jiti@2.7.0)) eslint-plugin-react-hooks: - specifier: 5.2.0 - version: 5.2.0(eslint@10.11.0(jiti@2.7.0)) + specifier: 7.1.1 + version: 7.1.1(eslint@10.11.0(jiti@2.7.0)) eslint-plugin-turbo: specifier: ^2.11.3 version: 2.11.3(eslint@10.11.0(jiti@2.7.0))(turbo@2.11.3) globals: - specifier: 15.14.0 - version: 15.14.0 + specifier: 17.12.0 + version: 17.12.0 minimatch: specifier: 10.2.6 version: 10.2.6 @@ -493,9 +493,6 @@ importers: '@query/tsconfig': specifier: workspace:* version: link:../typescript - '@types/eslint__js': - specifier: 8.42.3 - version: 8.42.3 eslint: specifier: 10.11.0 version: 10.11.0(jiti@2.7.0) @@ -589,39 +586,106 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.29.3': resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@cacheable/memory@2.2.0': resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} @@ -824,11 +888,11 @@ packages: resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/compat@1.2.0': - resolution: {integrity: sha512-CkPWddN7J9JPrQedEr2X7AjK9y1jaMJtxZ4A/+jTMFA2+n5BWhcKHW/EbJyARqg2zzQfgtWUtVmG3hrG6+nGpg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/compat@2.1.1': + resolution: {integrity: sha512-rMcy8GSrwNzcISX/BlTDY/GLB4eCopEuy9woIls3To+15OLxykZrxxq+WUcylCPCQ6F4MujjBM1DX5V1aqI3Vw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} peerDependencies: - eslint: ^9.10.0 + eslint: ^8.40 || 9 || 10 peerDependenciesMeta: eslint: optional: true @@ -849,9 +913,14 @@ packages: resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/js@9.17.0': - resolution: {integrity: sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true '@eslint/object-schema@3.0.5': resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} @@ -2076,12 +2145,6 @@ packages: '@types/emscripten@1.41.6': resolution: {integrity: sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==} - '@types/eslint@9.6.1': - resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - - '@types/eslint__js@8.42.3': - resolution: {integrity: sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw==} - '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -2602,6 +2665,9 @@ packages: resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==} engines: {node: '>=18'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@4.1.1: resolution: {integrity: sha512-AoT6Imdr98feSpFfmFwTFN73ccdr7uFPf27cBCgYvyyRyn1BzLRxMvrHNmwXO5LJMddRy4Rdhw2b1h7vSMKsEw==} engines: {node: '>=18'} @@ -2946,12 +3012,6 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-config-prettier@9.1.0: - resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - eslint-import-resolver-node@0.3.10: resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} @@ -2992,11 +3052,11 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - eslint-plugin-react-hooks@5.2.0: - resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} - engines: {node: '>=10'} + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {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 + 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 eslint-plugin-react@7.37.5: resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} @@ -3193,6 +3253,10 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -3224,8 +3288,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - globals@15.14.0: - resolution: {integrity: sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==} + globals@17.12.0: + resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} engines: {node: '>=18'} globalthis@1.0.4: @@ -3286,6 +3350,12 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -3504,6 +3574,11 @@ packages: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -3624,6 +3699,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.48.0: resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==} peerDependencies: @@ -4707,6 +4785,9 @@ packages: y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@18.1.3: resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} engines: {node: '>=6'} @@ -4723,6 +4804,12 @@ packages: resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} engines: {node: '>=18'} + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod@4.6.5: resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} @@ -4757,6 +4844,34 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + '@babel/generator@7.29.1': dependencies: '@babel/parser': 7.29.3 @@ -4765,22 +4880,77 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.1 + lru-cache: 5.1.1 + semver: 6.3.1 + '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 '@babel/parser': 7.29.3 '@babel/types': 7.29.0 + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -4793,11 +4963,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@cacheable/memory@2.2.0': dependencies: '@cacheable/utils': 2.5.0 @@ -4923,7 +5110,9 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} - '@eslint/compat@1.2.0(eslint@10.11.0(jiti@2.7.0))': + '@eslint/compat@2.1.1(eslint@10.11.0(jiti@2.7.0))': + dependencies: + '@eslint/core': 1.2.1 optionalDependencies: eslint: 10.11.0(jiti@2.7.0) @@ -4947,7 +5136,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@9.17.0': {} + '@eslint/js@10.0.1(eslint@10.11.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.11.0(jiti@2.7.0) '@eslint/object-schema@3.0.5': {} @@ -5969,15 +6160,6 @@ snapshots: '@types/emscripten@1.41.6': {} - '@types/eslint@9.6.1': - dependencies: - '@types/estree': 1.0.9 - '@types/json-schema': 7.0.15 - - '@types/eslint__js@8.42.3': - dependencies: - '@types/eslint': 9.6.1 - '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -6582,6 +6764,8 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 + convert-source-map@2.0.0: {} + copy-anything@4.1.1: {} core-util-is@1.0.3: {} @@ -6908,10 +7092,6 @@ snapshots: dependencies: eslint: 10.11.0(jiti@2.7.0) - eslint-config-prettier@9.1.0(eslint@10.11.0(jiti@2.7.0)): - dependencies: - eslint: 10.11.0(jiti@2.7.0) - eslint-import-resolver-node@0.3.10: dependencies: debug: 3.2.7 @@ -6978,9 +7158,16 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@5.2.0(eslint@10.11.0(jiti@2.7.0)): + eslint-plugin-react-hooks@7.1.1(eslint@10.11.0(jiti@2.7.0)): dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.9 eslint: 10.11.0(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.6.5 + zod-validation-error: 4.0.2(zod@4.6.5) + transitivePeerDependencies: + - supports-color eslint-plugin-react@7.37.5(eslint@10.11.0(jiti@2.7.0)): dependencies: @@ -7226,6 +7413,8 @@ snapshots: generator-function@2.0.1: {} + gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} get-intrinsic@1.3.0: @@ -7266,7 +7455,7 @@ snapshots: dependencies: is-glob: 4.0.3 - globals@15.14.0: {} + globals@17.12.0: {} globalthis@1.0.4: dependencies: @@ -7331,6 +7520,12 @@ snapshots: dependencies: function-bind: 1.1.2 + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + hookified@1.15.1: {} hookified@2.2.0: {} @@ -7540,6 +7735,8 @@ snapshots: dependencies: minimist: 1.2.8 + json5@2.2.3: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -7644,6 +7841,10 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + lucide-react@1.48.0(react@19.3.0): dependencies: react: 19.3.0 @@ -8770,6 +8971,8 @@ snapshots: y18n@4.0.3: {} + yallist@3.1.1: {} + yargs-parser@18.1.3: dependencies: camelcase: 5.3.1 @@ -8797,6 +9000,10 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 + zod-validation-error@4.0.2(zod@4.6.5): + dependencies: + zod: 4.6.5 + zod@4.6.5: {} zxing-wasm@3.1.3: diff --git a/sites/hacklytics2027/app/page.tsx b/sites/hacklytics2027/app/page.tsx index f135b41f..a044f74e 100644 --- a/sites/hacklytics2027/app/page.tsx +++ b/sites/hacklytics2027/app/page.tsx @@ -1,5 +1,5 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useSyncExternalStore } from "react"; import HomeSections from "@/components/HomeSections"; import PixelGarden, { PixelGround } from "@/components/pixel/PixelGarden"; import PixelSprite from "@/components/pixel/PixelSprite"; @@ -57,6 +57,8 @@ const FloralBackground = () => ( ); // ─── Countdown ──────────────────────────────────────────────────────────── +const subscribeNoop = () => () => {}; + const Countdown: React.FC<{ targetDate: Date }> = ({ targetDate }) => { const getTimeLeft = React.useCallback(() => { const distance = targetDate.getTime() - Date.now(); @@ -69,12 +71,12 @@ const Countdown: React.FC<{ targetDate: Date }> = ({ targetDate }) => { }; }, [targetDate]); - const [timeLeft, setTimeLeft] = useState<{ days: number; hours: number; minutes: number; seconds: number } | null>(null); - const [mounted, setMounted] = useState(false); + const [timeLeft, setTimeLeft] = useState(getTimeLeft); + // False for the static HTML and hydration, true after: the build-time + // countdown would be stale by the time anyone loads the page. + const mounted = useSyncExternalStore(subscribeNoop, () => true, () => false); useEffect(() => { - setMounted(true); - setTimeLeft(getTimeLeft()); const id = setInterval(() => setTimeLeft(getTimeLeft()), 1000); return () => clearInterval(id); }, [getTimeLeft]); diff --git a/sites/hacklytics2027/components/LazySection.tsx b/sites/hacklytics2027/components/LazySection.tsx index 673a5f15..0a6d7c28 100644 --- a/sites/hacklytics2027/components/LazySection.tsx +++ b/sites/hacklytics2027/components/LazySection.tsx @@ -32,12 +32,6 @@ export default function LazySection({ const el = ref.current; if (!el) return; - // If IntersectionObserver isn't available, render immediately - if (typeof IntersectionObserver === "undefined") { - setIsVisible(true); - return; - } - const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index bd3fc79b..1df81258 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -5,6 +5,7 @@ import { Zap } from "lucide-react"; import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; import { usePortalContext } from "@/lib/use-portal-context"; +import { useIsClient } from "@/lib/use-is-client"; import { useRouter } from "next/navigation"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { JudgingTools } from "@/components/admin/judging/JudgingTools"; @@ -18,7 +19,7 @@ import { judgingPrepIsCurrent } from "@/lib/judging-prep"; export default function AdminResultsPage() { const { data: session, status } = useSession(); const router = useRouter(); - const [mounted, setMounted] = useState(false); + const mounted = useIsClient(); const [selectedHackathon, setSelectedHackathon] = useState( null, ); @@ -76,7 +77,6 @@ export default function AdminResultsPage() { null, ); const selectedHackathonRef = useRef(selectedHackathon); - selectedHackathonRef.current = selectedHackathon; const prepGen = useRef(0); const promoteSubmissions = trpc.judge.promoteSubmissions.useMutation(); @@ -172,10 +172,6 @@ export default function AdminResultsPage() { } }; - useEffect(() => { - setMounted(true); - }, []); - useEffect(() => { if (status === "unauthenticated") { router.push("/login"); @@ -183,16 +179,21 @@ export default function AdminResultsPage() { }, [status, router]); // Auto-select first hackathon - useEffect(() => { - if (hackathons?.[0] && !selectedHackathon) { - setSelectedHackathon(hackathons[0].id); - } - }, [hackathons, selectedHackathon]); + if (hackathons?.[0] && !selectedHackathon) { + setSelectedHackathon(hackathons[0].id); + } + + // A new selection drops the previous edition's prep result and conflict. + const [prepShownFor, setPrepShownFor] = useState(selectedHackathon); + if (prepShownFor !== selectedHackathon) { + setPrepShownFor(selectedHackathon); + setAssignConflictId(null); + setPrepState({ busy: false, message: null, error: null }); + } useEffect(() => { + selectedHackathonRef.current = selectedHackathon; prepGen.current += 1; - setAssignConflictId(null); - setPrepState({ busy: false, message: null, error: null }); }, [selectedHackathon]); const categories = useMemo(() => { diff --git a/sites/mainweb/app/(portal)/admin/resumes/page.tsx b/sites/mainweb/app/(portal)/admin/resumes/page.tsx index 5030c705..f8150eab 100644 --- a/sites/mainweb/app/(portal)/admin/resumes/page.tsx +++ b/sites/mainweb/app/(portal)/admin/resumes/page.tsx @@ -51,11 +51,13 @@ export default function AdminResumesPage() { // A selection or page carried across a filter change would put people in a // book the list said it would not. - useEffect(() => { + const [filterShown, setFilterShown] = useState({ scope, debounced }); + if (filterShown.scope !== scope || filterShown.debounced !== debounced) { + setFilterShown({ scope, debounced }); setSelected(new Set()); setPreview(null); setOffset(0); - }, [scope, debounced]); + } const allChecked = rows.length > 0 && rows.every((r) => selected.has(r.userId)); diff --git a/sites/mainweb/app/(portal)/club/page.tsx b/sites/mainweb/app/(portal)/club/page.tsx index 680d53ee..15d48f42 100644 --- a/sites/mainweb/app/(portal)/club/page.tsx +++ b/sites/mainweb/app/(portal)/club/page.tsx @@ -118,11 +118,13 @@ export default function ClubPage() { } }, [status, memberStatus, router]); - useEffect(() => { - if (!showScanner) { - setScannedCodes(new Set()); - } - }, [showScanner]); + // Closing the scanner forgets what it saw, so reopening can scan the same + // code again. + const [scannerWasOpen, setScannerWasOpen] = useState(showScanner); + if (showScanner !== scannerWasOpen) { + setScannerWasOpen(showScanner); + if (!showScanner) setScannedCodes(new Set()); + } const handleScan = async (detectedCodes: { rawValue: string }[]) => { if (isProcessing || !detectedCodes || detectedCodes.length === 0) return; diff --git a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx index 73617ba5..91ac316a 100644 --- a/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx +++ b/sites/mainweb/app/(portal)/hackathons/[id]/judge/page.tsx @@ -128,17 +128,16 @@ export default function JudgeHackathonPage() { // Seeds the first project only. Once a mutation has taken over, `current` or // `done` is set and a late-arriving response must not replace what the judge // is looking at. - useEffect(() => { - if (!nextTable.data || current || done) return; + if (nextTable.data && !current && !done) { if (nextTable.data.done) { setDone(true); - return; + } else { + setCurrent({ + project: (nextTable.data.project as Project) ?? null, + queueId: nextTable.data.queueId ?? null, + }); } - setCurrent({ - project: (nextTable.data.project as Project) ?? null, - queueId: nextTable.data.queueId ?? null, - }); - }, [nextTable.data, current, done]); + } const advance = ( project: Project | null, diff --git a/sites/mainweb/app/(portal)/judge/page.tsx b/sites/mainweb/app/(portal)/judge/page.tsx index d3b7993e..3b16547c 100644 --- a/sites/mainweb/app/(portal)/judge/page.tsx +++ b/sites/mainweb/app/(portal)/judge/page.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import { trpc } from "@/lib/trpc"; +import { useIsClient } from "@/lib/use-is-client"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import Link from "next/link"; @@ -24,7 +25,7 @@ type HackathonData = { export default function JudgePage() { const { data: session, status } = useSession(); const router = useRouter(); - const [mounted, setMounted] = useState(false); + const mounted = useIsClient(); const { data: judgeStatus, isLoading: checkingJudge } = trpc.judge.isJudge.useQuery(undefined, { enabled: !!session }); @@ -41,8 +42,6 @@ export default function JudgePage() { enabled: !!session, }); - useEffect(() => setMounted(true), []); - // The approval email points here. Rendering nothing for a signed-out visitor // turned an expired session into a broken link. useEffect(() => { diff --git a/sites/mainweb/app/(portal)/login/page.tsx b/sites/mainweb/app/(portal)/login/page.tsx index f0aa38fc..95b42b63 100644 --- a/sites/mainweb/app/(portal)/login/page.tsx +++ b/sites/mainweb/app/(portal)/login/page.tsx @@ -5,6 +5,7 @@ import { useSession, signIn, getProviders } from "next-auth/react"; import { useRouter, useSearchParams } from "next/navigation"; import { usePortalContext } from "@/lib/use-portal-context"; import { safeCallback } from "@/lib/safe-callback"; +import { useIsClient } from "@/lib/use-is-client"; // DSGT Query - Premium Landing Page // Ultra-modern, standout UI/UX @@ -14,7 +15,7 @@ export default function Home() { const router = useRouter(); const searchParams = useSearchParams(); const callbackUrl = safeCallback(searchParams.get("callbackUrl")); - const [mounted, setMounted] = useState(false); + const mounted = useIsClient(); const [showEmailInput, setShowEmailInput] = useState(false); const [email, setEmail] = useState(""); const [emailSending, setEmailSending] = useState(false); @@ -37,7 +38,6 @@ export default function Home() { ); useEffect(() => { - setMounted(true); getProviders() .then((p) => setProviders(p ?? {})) // A failed lookup must not hide every sign-in button. Falling back to diff --git a/sites/mainweb/app/(portal)/settings/page.tsx b/sites/mainweb/app/(portal)/settings/page.tsx index d4f6e0d7..12ca0bb1 100644 --- a/sites/mainweb/app/(portal)/settings/page.tsx +++ b/sites/mainweb/app/(portal)/settings/page.tsx @@ -25,6 +25,7 @@ import { ResumeSection } from "@/components/portal/ResumeSection"; import Image from "next/image"; import { trpc } from "@/lib/trpc"; import { trpcErrorMessage } from "@/lib/trpc-error"; +import { useIsClient } from "@/lib/use-is-client"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; @@ -32,7 +33,7 @@ export default function SettingsPage() { const { data: session, status } = useSession(); const router = useRouter(); const { theme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); + const mounted = useIsClient(); const [activeTab, setActiveTab] = useState< "profile" | "membership" | "appearance" | "account" >("profile"); @@ -123,23 +124,23 @@ export default function SettingsPage() { gtEmail: "", }); - useEffect(() => { setMounted(true); }, []); - useEffect(() => { if (status === "unauthenticated") router.push("/login"); }, [status, router]); - useEffect(() => { - if (userData) { - setForm({ - name: userData.name || "", - bio: userData.bio || "", - website: userData.website || "", - location: userData.location || "", - gtEmail: userData.gtEmail || "", - }); - } - }, [userData]); + // Refill the form whenever new user data arrives. Done during render rather + // than in an effect, so the stale form never paints. + const [filledFrom, setFilledFrom] = useState(undefined); + if (userData && userData !== filledFrom) { + setFilledFrom(userData); + setForm({ + name: userData.name || "", + bio: userData.bio || "", + website: userData.website || "", + location: userData.location || "", + gtEmail: userData.gtEmail || "", + }); + } const handleSave = async () => { setIsSaving(true); diff --git a/sites/mainweb/app/(portal)/submit/page.tsx b/sites/mainweb/app/(portal)/submit/page.tsx index dc0fd911..55d7631b 100644 --- a/sites/mainweb/app/(portal)/submit/page.tsx +++ b/sites/mainweb/app/(portal)/submit/page.tsx @@ -3,7 +3,7 @@ import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useState } from "react"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; import Link from "next/link"; @@ -191,24 +191,22 @@ function SubmitPortalContent() { onError: (err) => setError(err.message), }); - useEffect(() => { - if (!selectedHackathonId && myRegs && myRegs.length > 0) { - const firstReg = myRegs[0]; - if (firstReg) { - setSelectedHackathonId(firstReg.hackathonId); - } + if (!selectedHackathonId && myRegs && myRegs.length > 0) { + const firstReg = myRegs[0]; + if (firstReg) { + setSelectedHackathonId(firstReg.hackathonId); } - }, [myRegs, selectedHackathonId]); + } // Fill the form from the existing submission, once per selected event. // Clearing when there is none matters as much as filling: the form used to // keep the previous event's answers after switching, so a submit could file // one hackathon's project against another. - useEffect(() => { - if (!selectedHackathonId) return; - if (mySubmission.isPending) return; - if (prefilledFor === selectedHackathonId) return; - + if ( + selectedHackathonId && + !mySubmission.isPending && + prefilledFor !== selectedHackathonId + ) { const p = mySubmission.data; setProjectName(p?.name ?? ""); setProjectDesc(p?.description ?? ""); @@ -219,12 +217,7 @@ function SubmitPortalContent() { setChallenges(p?.challenges ?? []); setIsCreateX(p?.isCreateX ?? false); setPrefilledFor(selectedHackathonId); - }, [ - selectedHackathonId, - mySubmission.isPending, - mySubmission.data, - prefilledFor, - ]); + } if (status === "loading" || loadingRegs) { return ; diff --git a/sites/mainweb/app/(portal)/verify/page.tsx b/sites/mainweb/app/(portal)/verify/page.tsx index b86b4cdf..4d3064b9 100644 --- a/sites/mainweb/app/(portal)/verify/page.tsx +++ b/sites/mainweb/app/(portal)/verify/page.tsx @@ -98,8 +98,9 @@ function VerifyContent() { // /dashboard by the route, so the `||` below can never fall through to // anything else — reading the query param first is what actually // returns somebody to the page that sent them here. - window.location.href = - callbackUrl || data.redirectUrl || "/dashboard"; + window.location.assign( + callbackUrl || data.redirectUrl || "/dashboard", + ); } else { setError(data.error || "Invalid code. Please try again."); setVerifying(false); diff --git a/sites/mainweb/app/HomePageClient.tsx b/sites/mainweb/app/HomePageClient.tsx index 5f687bbd..35f64f1a 100644 --- a/sites/mainweb/app/HomePageClient.tsx +++ b/sites/mainweb/app/HomePageClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo } from "react"; import Image from "next/image"; import type { StaticImageData } from "next/image"; import Link from "next/link"; @@ -14,6 +14,7 @@ import { joinLabel, } from "@/lib/club-projects"; import type { ClubProjectCard } from "@/lib/club-projects"; +import { useWindowWidth } from "@/lib/use-window-width"; import Navbar from "@/components/Navbar"; import Hero from "@/components/Hero"; @@ -57,7 +58,7 @@ const PROJECT_LOGOS: Record = { }; const HomePageClient = ({ projects }: { projects: ClubProjectCard[] }) => { - const [windowWidth, setWindowWidth] = useState(1024); + const windowWidth = useWindowWidth(1024); const [chartsReady, setChartsReady] = useState(false); useEffect(() => { @@ -67,16 +68,6 @@ const HomePageClient = ({ projects }: { projects: ClubProjectCard[] }) => { }); }, []); - const handleResize = useCallback(() => { - setWindowWidth(window.innerWidth); - }, []); - - useEffect(() => { - handleResize(); - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, [handleResize]); - const chartOptions = useMemo( () => ({ responsive: true, diff --git a/sites/mainweb/app/bootcamp/page.tsx b/sites/mainweb/app/bootcamp/page.tsx index 61018c31..87e9ee45 100644 --- a/sites/mainweb/app/bootcamp/page.tsx +++ b/sites/mainweb/app/bootcamp/page.tsx @@ -1,24 +1,17 @@ "use client"; -import { useState, useEffect } from "react"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; import { BOOTCAMP_CURRICULUM, BOOTCAMP_START_DATE, } from "@/lib/bootcamp-schedule"; +import { useWindowWidth } from "@/lib/use-window-width"; const curriculum = BOOTCAMP_CURRICULUM; export default function BootcampPage() { - const [windowWidth, setWindowWidth] = useState(1024); - - useEffect(() => { - const handleResize = () => setWindowWidth(window.innerWidth); - setWindowWidth(window.innerWidth); - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); + const windowWidth = useWindowWidth(1024); return (
diff --git a/sites/mainweb/app/not-found.tsx b/sites/mainweb/app/not-found.tsx index ec1107a6..f3e0fdad 100644 --- a/sites/mainweb/app/not-found.tsx +++ b/sites/mainweb/app/not-found.tsx @@ -1,21 +1,14 @@ // src/app/not-found.tsx "use client"; -import { useState, useEffect } from "react"; import Link from "next/link"; import Navbar from "@/components/Navbar"; import Footer from "@/components/Footer"; +import { useWindowWidth } from "@/lib/use-window-width"; export default function NotFound() { - const [windowWidth, setWindowWidth] = useState(1024); - - useEffect(() => { - // Sync window width for Navbar responsiveness - setWindowWidth(window.innerWidth); - const handleResize = () => setWindowWidth(window.innerWidth); - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); + // Window width for Navbar responsiveness + const windowWidth = useWindowWidth(1024); return (
diff --git a/sites/mainweb/components/Hero/index.tsx b/sites/mainweb/components/Hero/index.tsx index 7c8a7364..01e7b3fe 100644 --- a/sites/mainweb/components/Hero/index.tsx +++ b/sites/mainweb/components/Hero/index.tsx @@ -1,7 +1,6 @@ // src/components/Hero.tsx "use client"; -import { useState, useEffect } from "react"; import Image from "next/image"; import Mini from "../Text/Mini"; @@ -14,13 +13,9 @@ interface HeroProps { } const Hero = ({ screen_width }: HeroProps) => { - const [windowWidth, setWindowWidth] = useState(screen_width); + const windowWidth = screen_width; const WIDTH_THRESHOLD = 1000; - useEffect(() => { - setWindowWidth(screen_width); - }, [screen_width]); - return (
{ - setWindowWidth(window.innerWidth); const handleResize = () => { - setWindowWidth(window.innerWidth); if (window.innerWidth >= WIDTH_THRESHOLD) setMenuOpen(false); }; window.addEventListener("resize", handleResize); diff --git a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx index ec25f2aa..6656b5f9 100644 --- a/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx +++ b/sites/mainweb/components/admin/hackathons/AttendeesTab.tsx @@ -184,7 +184,7 @@ export function AttendeesTab({ setExporting(true); let all: Awaited< ReturnType - > = []; + >; try { all = await utils.hackathon.exportAttendees.fetch({ hackathonId, diff --git a/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx b/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx index 588bf4e4..64bd530a 100644 --- a/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx +++ b/sites/mainweb/components/admin/hackathons/EditHackathonForm.tsx @@ -65,33 +65,31 @@ export function EditHackathonForm({ const [error, setError] = useState(""); const [loaded, setLoaded] = useState(false); - useEffect(() => { - if (hackathon && !loaded) { - setName(hackathon.name); - setDescription(hackathon.description || ""); - setLocation(hackathon.location || ""); - setStartDate(toInputDate(hackathon.startDate)); - setEndDate(toInputDate(hackathon.endDate)); - setHackingStartTime( - hackathon.hackingStartTime - ? toInputDate(hackathon.hackingStartTime) - : "", - ); - setRegDeadline( - hackathon.registrationDeadline - ? toInputDate(hackathon.registrationDeadline) - : "", - ); - setMaxParticipants(hackathon.maxParticipants?.toString() || ""); - setTheme(hackathon.theme || ""); - setTracks((hackathon.tracks ?? []).join(", ")); - setChallenges((hackathon.challenges ?? []).join(", ")); - setRules(hackathon.rules || ""); - setWebsiteUrl(hackathon.websiteUrl || ""); - setStatus(hackathon.status as HackathonStatus); - setLoaded(true); - } - }, [hackathon, loaded]); + // Seed the form once, during render rather than in an effect, so the empty + // form never paints. + if (hackathon && !loaded) { + setName(hackathon.name); + setDescription(hackathon.description || ""); + setLocation(hackathon.location || ""); + setStartDate(toInputDate(hackathon.startDate)); + setEndDate(toInputDate(hackathon.endDate)); + setHackingStartTime( + hackathon.hackingStartTime ? toInputDate(hackathon.hackingStartTime) : "", + ); + setRegDeadline( + hackathon.registrationDeadline + ? toInputDate(hackathon.registrationDeadline) + : "", + ); + setMaxParticipants(hackathon.maxParticipants?.toString() || ""); + setTheme(hackathon.theme || ""); + setTracks((hackathon.tracks ?? []).join(", ")); + setChallenges((hackathon.challenges ?? []).join(", ")); + setRules(hackathon.rules || ""); + setWebsiteUrl(hackathon.websiteUrl || ""); + setStatus(hackathon.status as HackathonStatus); + setLoaded(true); + } const updateMutation = trpc.hackathon.update.useMutation({ onSuccess: () => onSaved(), diff --git a/sites/mainweb/components/hackathon/InterestForm.tsx b/sites/mainweb/components/hackathon/InterestForm.tsx index e46f029d..e45c0409 100644 --- a/sites/mainweb/components/hackathon/InterestForm.tsx +++ b/sites/mainweb/components/hackathon/InterestForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import Link from "next/link"; import { useSession } from "next-auth/react"; import { trpc } from "@/lib/trpc"; @@ -70,15 +70,18 @@ export function InterestForm({ const [editing, setEditing] = useState(false); const [error, setError] = useState(""); - useEffect(() => { - if (!mine.data) return; + // Refill the form whenever a new answer arrives. Done during render rather + // than in an effect, so the stale form never paints. + const [filledFrom, setFilledFrom] = useState(undefined); + if (mine.data && mine.data !== filledFrom) { + setFilledFrom(mine.data); setSchool(mine.data.school ?? ""); setCountry(mine.data.country ?? ""); setGraduationYear( mine.data.graduationYear ? String(mine.data.graduationYear) : "", ); setExperience((mine.data.experience as Experience) ?? ""); - }, [mine.data]); + } const refresh = async () => { await Promise.all([ diff --git a/sites/mainweb/components/portal/AdminLayout.tsx b/sites/mainweb/components/portal/AdminLayout.tsx index fa436766..eeea7be8 100644 --- a/sites/mainweb/components/portal/AdminLayout.tsx +++ b/sites/mainweb/components/portal/AdminLayout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; import { usePortalContext } from "@/lib/use-portal-context"; @@ -11,7 +11,6 @@ export default function AdminLayout({ children: React.ReactNode; }) { const { status } = useSession(); - const [loading, setLoading] = useState(true); const router = useRouter(); const { data: portalContext, isLoading: portalLoading } = usePortalContext(); @@ -22,12 +21,16 @@ export default function AdminLayout({ } else if (status === "authenticated" && !portalLoading) { if (!portalContext?.isAdmin) { router.push("/dashboard"); - } else { - setLoading(false); } } }, [status, portalContext?.isAdmin, portalLoading, router]); + const loading = !( + status === "authenticated" && + !portalLoading && + portalContext?.isAdmin + ); + if (loading) { return (
diff --git a/sites/mainweb/components/portal/EventFormModal.tsx b/sites/mainweb/components/portal/EventFormModal.tsx index 1c498dca..fc518d8f 100644 --- a/sites/mainweb/components/portal/EventFormModal.tsx +++ b/sites/mainweb/components/portal/EventFormModal.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { ModalWrapper } from "./ModalWrapper"; interface EventFormData { @@ -48,23 +48,15 @@ export function EventFormModal({ title: initial?.title ?? "", description: initial?.description ?? "", location: initial?.location ?? "", - eventDate: initial?.eventDate ?? "", + // Auto-fill date/time when creating. An edit already carries the event's + // own date, and overwriting it with "now" would silently reschedule it. + eventDate: initial?.eventDate || getCurrentDateTimeLocal(), maxCheckIns: initial?.maxCheckIns ?? "", membersOnly: initial?.membersOnly ?? true, bootcampWeek: initial?.bootcampWeek ?? "", bootcampOnly: initial?.bootcampOnly ?? false, }); - // Auto-fill date/time when creating. An edit already carries the event's own - // date, and overwriting it with "now" would silently reschedule it. - useEffect(() => { - if (initial?.eventDate) return; - setForm((prev) => ({ - ...prev, - eventDate: getCurrentDateTimeLocal(), - })); - }, [initial?.eventDate]); - const handleSubmit = () => { onSubmit(form); }; diff --git a/sites/mainweb/components/portal/MembershipTab.tsx b/sites/mainweb/components/portal/MembershipTab.tsx index 9940a6d5..fba6e3d9 100644 --- a/sites/mainweb/components/portal/MembershipTab.tsx +++ b/sites/mainweb/components/portal/MembershipTab.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { trpc } from "@/lib/trpc"; import SkillsInterestsInput from "@/components/portal/profile/SkillsInterestsInput"; @@ -46,8 +46,11 @@ export function MembershipTab() { const [error, setError] = useState(null); const [saved, setSaved] = useState(false); - useEffect(() => { - if (!member) return; + // Refill the form whenever a new member record arrives. Done during render + // rather than in an effect, so the stale form never paints. + const [filledFrom, setFilledFrom] = useState(undefined); + if (member && member !== filledFrom) { + setFilledFrom(member); setForm({ firstName: member.firstName ?? "", lastName: member.lastName ?? "", @@ -60,7 +63,7 @@ export function MembershipTab() { }); setSkills(member.skills ?? []); setInterests(member.interests ?? []); - }, [member]); + } const done = () => { setError(null); diff --git a/sites/mainweb/components/portal/PortalSidebar.tsx b/sites/mainweb/components/portal/PortalSidebar.tsx index ee1ddd78..7d0f661a 100644 --- a/sites/mainweb/components/portal/PortalSidebar.tsx +++ b/sites/mainweb/components/portal/PortalSidebar.tsx @@ -16,6 +16,7 @@ import { } from "lucide-react"; import { useTheme } from "next-themes"; import { usePortalContext } from "@/lib/use-portal-context"; +import { useIsClient } from "@/lib/use-is-client"; import { isPortalNavActive, portalNavSections } from "@/lib/portal-nav"; import logo from "../../assets/images/dsgt/apple-touch-icon.png"; @@ -32,15 +33,11 @@ export default function PortalSidebar({ const pathname = usePathname(); const { data: session } = useSession(); const { theme, setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); + const mounted = useIsClient(); const { data: portalContext } = usePortalContext(); const sections = portalNavSections(portalContext); - useEffect(() => { - setMounted(true); - }, []); - // Prevent scrolling when mobile menu is open useEffect(() => { if (isMobileOpen) document.body.style.overflow = "hidden"; diff --git a/sites/mainweb/components/portal/ResumePreview.tsx b/sites/mainweb/components/portal/ResumePreview.tsx index 115b32d7..5afa9bc6 100644 --- a/sites/mainweb/components/portal/ResumePreview.tsx +++ b/sites/mainweb/components/portal/ResumePreview.tsx @@ -10,16 +10,20 @@ import { looksLikePdf } from "@/lib/resume-file"; * even when the bytes are fine. */ export function ResumePreview({ src, title }: { src: string; title: string }) { - const [blobUrl, setBlobUrl] = useState(null); - const [error, setError] = useState(null); + // Keyed by src, so a new src starts from the loading state instead of + // briefly showing the previous resume or its error. + const [loaded, setLoaded] = useState<{ + src: string; + blobUrl: string | null; + error: string | null; + }>({ src, blobUrl: null, error: null }); + const blobUrl = loaded.src === src ? loaded.blobUrl : null; + const error = loaded.src === src ? loaded.error : null; useEffect(() => { let objectUrl: string | undefined; let cancelled = false; - setBlobUrl(null); - setError(null); - fetch(src, { credentials: "same-origin" }) .then(async (res) => { if (!res.ok) { @@ -39,13 +43,16 @@ export function ResumePreview({ src, title }: { src: string; title: string }) { .then((blob) => { if (cancelled) return; objectUrl = URL.createObjectURL(blob); - setBlobUrl(objectUrl); + setLoaded({ src, blobUrl: objectUrl, error: null }); }) .catch((err) => { if (cancelled) return; - setError( - err instanceof Error ? err.message : "Could not load that resume.", - ); + setLoaded({ + src, + blobUrl: null, + error: + err instanceof Error ? err.message : "Could not load that resume.", + }); }); return () => { diff --git a/sites/mainweb/components/portal/StripePaymentModal.tsx b/sites/mainweb/components/portal/StripePaymentModal.tsx index 015b1672..e3e3e87d 100644 --- a/sites/mainweb/components/portal/StripePaymentModal.tsx +++ b/sites/mainweb/components/portal/StripePaymentModal.tsx @@ -9,11 +9,12 @@ import { useElements, } from "@stripe/react-stripe-js"; import { loadStripe } from "@stripe/stripe-js"; -import type { Stripe, StripeElementsOptions } from "@stripe/stripe-js"; +import type { StripeElementsOptions } from "@stripe/stripe-js"; import { X, Shield, Lock } from "lucide-react"; import { useState } from "react"; import { useTheme } from "next-themes"; import { formatCents } from "@query/api/pricing"; +import { useIsClient } from "@/lib/use-is-client"; // ── Inner form (must be inside ) ───────────────────────────────── function CheckoutForm({ @@ -213,19 +214,15 @@ export function StripePaymentModal({ onUnconfirmed, amountCents, }: StripePaymentModalProps) { - const [stripePromise, setStripePromise] = - useState | null>(null); + const stripePromise = useMemo( + () => (!isMock && publishableKey ? loadStripe(publishableKey) : null), + [publishableKey, isMock], + ); // Used only by the mock branch below; the real flow keeps its own state // inside the Elements form. const [processing, setProcessing] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - if (!isMock && publishableKey) { - setStripePromise(loadStripe(publishableKey)); - } - }, [publishableKey, isMock]); - // Close on Escape const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -400,8 +397,7 @@ function ModalShell({ * A portal puts it at the top level where `fixed inset-0` means the whole * viewport. */ - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); + const mounted = useIsClient(); if (!mounted) return null; return createPortal( diff --git a/sites/mainweb/lib/use-is-client.ts b/sites/mainweb/lib/use-is-client.ts new file mode 100644 index 00000000..662830ab --- /dev/null +++ b/sites/mainweb/lib/use-is-client.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +const subscribe = () => () => {}; + +/** + * False during server rendering and hydration, true on every client render + * after. Replaces the `useEffect(() => setMounted(true), [])` pattern, which + * the React Compiler lint rejects as a cascading render. + */ +export function useIsClient() { + return useSyncExternalStore( + subscribe, + () => true, + () => false, + ); +} diff --git a/sites/mainweb/lib/use-window-width.ts b/sites/mainweb/lib/use-window-width.ts new file mode 100644 index 00000000..97a72cd0 --- /dev/null +++ b/sites/mainweb/lib/use-window-width.ts @@ -0,0 +1,20 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +function subscribe(onChange: () => void) { + window.addEventListener("resize", onChange); + return () => window.removeEventListener("resize", onChange); +} + +/** + * `window.innerWidth`, kept current on resize. `serverWidth` is what the + * server render and hydration see, before the real width is known. + */ +export function useWindowWidth(serverWidth: number) { + return useSyncExternalStore( + subscribe, + () => window.innerWidth, + () => serverWidth, + ); +} diff --git a/tooling/eslint/package.json b/tooling/eslint/package.json index b13d2882..a8d58b8b 100644 --- a/tooling/eslint/package.json +++ b/tooling/eslint/package.json @@ -16,25 +16,24 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@eslint/compat": "1.2.0", - "@eslint/js": "9.17.0", + "@eslint/compat": "2.1.1", + "@eslint/js": "10.0.1", "@next/eslint-plugin-next": "16.3.6", "@typescript-eslint/eslint-plugin": "8.70.1", "@typescript-eslint/parser": "8.70.1", - "eslint-config-prettier": "9.1.0", + "eslint-config-prettier": "10.1.8", "eslint-plugin-import": "2.32.0", "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.1.1", "eslint-plugin-turbo": "^2.11.3", - "globals": "15.14.0", + "globals": "17.12.0", "minimatch": "10.2.6", "typescript-eslint": "8.70.1" }, "devDependencies": { "@query/prettier-config": "workspace:*", "@query/tsconfig": "workspace:*", - "@types/eslint__js": "8.42.3", "eslint": "10.11.0", "prettier": "3.9.9", "typescript": "6.0.2" From 8d05dc9528a1fd40738f88d64515b219249e4458 Mon Sep 17 00:00:00 2001 From: aamoghS Date: Thu, 24 Sep 2026 15:31:48 -0400 Subject: [PATCH 2/2] fix: close stale-result windows in admin judging and resume preview - Admin judging: move the selection ref and prep generation in the click handler instead of a post-render effect. Between render and that effect, a prep run for the previous edition still passed stillThisRun and could land its result on the newly selected edition's panel. Re-selecting the current edition is now a no-op so it cannot orphan its own run. - ResumePreview: remount per src via key instead of keying state by src. Going A -> B -> A before B loaded matched A's retained blob URL, which the A -> B cleanup had already revoked, and showed a broken PDF. --- .../app/(portal)/admin/judging/page.tsx | 15 ++++++++-- .../components/portal/ResumePreview.tsx | 28 ++++++++----------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/sites/mainweb/app/(portal)/admin/judging/page.tsx b/sites/mainweb/app/(portal)/admin/judging/page.tsx index 1df81258..3cbb0f8e 100644 --- a/sites/mainweb/app/(portal)/admin/judging/page.tsx +++ b/sites/mainweb/app/(portal)/admin/judging/page.tsx @@ -191,11 +191,22 @@ export default function AdminResultsPage() { setPrepState({ busy: false, message: null, error: null }); } + // Covers the auto-select above, which runs before any prep can start. useEffect(() => { selectedHackathonRef.current = selectedHackathon; - prepGen.current += 1; }, [selectedHackathon]); + // The ref and generation move here, in the click, before B renders: a run + // still in flight for A fails stillThisRun from this moment, so its result + // cannot land on B's panel. Re-selecting the current edition is a no-op, so + // it cannot orphan that edition's own run with busy stuck on. + const selectHackathon = (id: string) => { + if (id === selectedHackathonRef.current) return; + selectedHackathonRef.current = id; + prepGen.current += 1; + setSelectedHackathon(id); + }; + const categories = useMemo(() => { if (!rankings?.rankings) return ["ALL"]; const cats = new Set( @@ -452,7 +463,7 @@ export default function AdminResultsPage() { ({ src, blobUrl: null, error: null }); - const blobUrl = loaded.src === src ? loaded.blobUrl : null; - const error = loaded.src === src ? loaded.error : null; + // Remount per src: a new src starts from the loading state, and a blob URL + // revoked by the previous src's cleanup can never be shown again. + return ; +} + +function ResumeFrame({ src, title }: { src: string; title: string }) { + const [blobUrl, setBlobUrl] = useState(null); + const [error, setError] = useState(null); useEffect(() => { let objectUrl: string | undefined; @@ -43,16 +42,13 @@ export function ResumePreview({ src, title }: { src: string; title: string }) { .then((blob) => { if (cancelled) return; objectUrl = URL.createObjectURL(blob); - setLoaded({ src, blobUrl: objectUrl, error: null }); + setBlobUrl(objectUrl); }) .catch((err) => { if (cancelled) return; - setLoaded({ - src, - blobUrl: null, - error: - err instanceof Error ? err.message : "Could not load that resume.", - }); + setError( + err instanceof Error ? err.message : "Could not load that resume.", + ); }); return () => {